diff --git a/CHANGELOG.md b/CHANGELOG.md index 11c8bad..b084be0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ### Added +* **`generate` runs until stopped when no `--count` is given, `cangen`-style (#477).** `canarchy generate ` with no `--count` previously sent a single frame and exited, unlike the reference `cangen` tool from can-utils, which streams continuously by default. `--count` now defaults to unbounded: omitting it generates and transmits frames continuously, spaced by `--gap`, until interrupted (Ctrl-C / SIGINT); `--count N` keeps its existing exact-`N`-frames behaviour. Frame generation streams one frame at a time (`iter_generated_frames`, `LocalTransport.generate_stream_events`) rather than materializing an unbounded list, and the CLI dispatches an unbounded live run to a streaming emitter — mirroring the existing `capture` / `gateway --text` pattern — so both text and JSON/JSONL output stream per-frame and SIGINT exits cleanly (exit 0). `--dry-run` without `--count` plans a single frame rather than requiring or attempting an unbounded plan. Entry points that cannot deliver Ctrl-C, such as the MCP server, get a structured `MISSING_COUNT` error instead of hanging on an unbounded live run. Closes #477. + * **SEO and link-preview metadata for the published site (#479, #480, #483).** The homepage now ships Open Graph and Twitter Card tags with a generated 1200×630 brand card (`src/homepage/og-card.png`), a canonical URL, an inline SVG favicon, a `SoftwareApplication` JSON-LD block, and a keyword-forward ``. The Pages build (`scripts/build_pages_site.sh`) additionally emits a root `robots.txt` referencing both the root and docs sitemaps and a root `sitemap.xml` covering the homepage, and publishes the social card. PyPI keywords in `pyproject.toml` expanded from 6 to 19 discovery terms. Closes #479, #480, #483. * **`canarchy tui` is now a full-screen Textual app that streams the CAN bus live.** The TUI was a line-oriented shell that reprinted six panes after each command finished — it had no live/async streaming (`/capture` ran to completion then dumped a batch), no interactivity (no pane focus, filter, sort, row navigation, or scrollback), and hard-coded tiny backlogs. It is now a full-screen [Textual](https://textual.textualize.io/) application whose headline feature is watching the bus in real time. The engine/view split is preserved: the deterministic fold layer in `src/canarchy/tui.py` (`TuiState`, `_update_state`, and per-pane row extractors) stays as the tested model, a new `src/canarchy/tui_app.py` consumes it as the view, and a new `src/canarchy/tui_capture.py` `CaptureSession` streams frames through the existing `LocalTransport.capture_stream_events` generator on a daemon thread + queue (mirroring the transport gateway pattern), drained on the UI loop so one frame at a time flows through the same folding logic batch results use. Panes (Bus Status, Live Traffic, Decoded Signals, J1939 summary ribbon + table, UDS, an append-only Alerts log) are interactive Textual widgets: `/capture <iface>` starts a background live capture and `/stop` (or the `x` key) ends it; `/filter <pane> [text]` and `/sort <pane> [column]` filter and sort a pane; arrow keys navigate rows; `[`/`]` resize the operator-controlled backlog; `space` pauses the live feed; `c` clears; `ctrl+f` maximizes the focused pane. The command entry still runs real CANarchy commands and slash hotkeys through the shared parser, and the nested-front-end guard (`TUI_COMMAND_UNSUPPORTED`) is retained. `textual` is now a core dependency. **Breaking:** the interactive-only rewrite retires the text-mode shell and the one-shot `tui --command` mode; the TUI requires an interactive terminal and a non-TTY invocation prints guidance and exits non-zero — use the individual commands for scripted/automation runs. New Pilot-based app tests (`tests/test_tui_app.py`) and `CaptureSession` unit tests (`tests/test_tui_capture.py`) drive the scaffold backend's deterministic frame stream; the fold-layer snapshot tests are unchanged. A remaining follow-up: a finite-timeout `capture_stream` loop so live capture stops instantly on real hardware. diff --git a/docs/command_spec.md b/docs/command_spec.md index b90d04d..e2c0ed8 100644 --- a/docs/command_spec.md +++ b/docs/command_spec.md @@ -225,12 +225,15 @@ Examples: canarchy generate can0 --id 0x123 --dlc 4 --data 11223344 --count 2 --gap 100 --json canarchy generate --id 0x123 --dlc 4 --data 11223344 --count 2 --dry-run --json canarchy generate can0 --data I --count 4 --text +canarchy generate can0 # runs until Ctrl-C, cangen-style ``` Notes: * `--id`, `--dlc`, and `--data` accept `R` for random generation, and `--data I` enables a deterministic incrementing payload pattern -* `--dry-run` emits the planned generated frame events without requiring an interface or opening a transport +* omitting `--count` generates and transmits frames continuously, spaced by `--gap`, until interrupted (Ctrl-C) — matching `cangen` from can-utils; passing `--count N` generates exactly `N` frames and exits +* `--dry-run` emits the planned generated frame events without requiring an interface or opening a transport; without `--count` a dry run plans a single frame +* invocations that cannot receive Ctrl-C (e.g. the MCP server) must pass `--count` explicitly for a live (non-dry-run) run, or a structured `MISSING_COUNT` error is returned * `--ack-active` requests an interactive `YES` confirmation before generated frames are transmitted * when active acknowledgement is required by configuration, omitting `--ack-active` returns a structured `ACTIVE_ACK_REQUIRED` error * generated JSON output includes an active-transmit alert followed by generated frame events diff --git a/docs/design/generate-command.md b/docs/design/generate-command.md index 35d43fe..4085547 100644 --- a/docs/design/generate-command.md +++ b/docs/design/generate-command.md @@ -22,7 +22,10 @@ Operators need a quick active-traffic workflow for lab validation, replay suppor |----|------|-------------| | `REQ-GENERATE-01` | Ubiquitous | The system shall provide a `canarchy generate <interface>` command for active CAN frame generation. | | `REQ-GENERATE-02` | Event-driven | When `generate` is invoked, the system shall support fixed, random (`R`), and incrementing (`I`) generation modes for identifiers and payloads as specified by `--id`, `--dlc`, and `--data`. | -| `REQ-GENERATE-03` | Event-driven | When `generate` is invoked, the system shall bound output by `--count` and space events by `--gap` milliseconds. | +| `REQ-GENERATE-03` | Event-driven | When `generate` is invoked with an explicit `--count`, the system shall bound output to that many frames and space events by `--gap` milliseconds. | +| `REQ-GENERATE-12` | Event-driven | When `generate` is invoked without `--count` from an interactive terminal, the system shall generate and transmit frames continuously, spaced by `--gap` milliseconds, until interrupted (Ctrl-C / SIGINT), matching `cangen`. | +| `REQ-GENERATE-13` | Unwanted behaviour | If `--dry-run` is supplied without `--count`, the system shall plan a single frame rather than requiring or attempting an unbounded plan. | +| `REQ-GENERATE-14` | Unwanted behaviour | If `generate` is invoked without `--count` and without `--dry-run` through a non-interactive entry point that cannot deliver Ctrl-C (e.g. the MCP server), the system shall return a structured error with code `MISSING_COUNT` and exit code 1 instead of generating indefinitely. | | `REQ-GENERATE-04` | Event-driven | When `generate` is invoked and validation succeeds, the system shall emit a preflight active-transmit warning to `stderr`, emit a leading active-transmit alert event, and serialise the generated frame events. | | `REQ-GENERATE-10` | Optional feature | Where `--ack-active` is supplied for `generate`, the system shall require a confirmation response of `YES` before generated frames are transmitted. | | `REQ-GENERATE-11` | Optional feature | Where active acknowledgement is required, the system shall require `--ack-active` before generated frames are transmitted. | @@ -48,7 +51,7 @@ canarchy generate <interface> [--id <hex|R>] [--dlc <0-8|R>] [--data <hex|R|I>] | `--id` | `R` | Arbitration ID as hex or `R` for random | | `--dlc` | `R` | Data length `0-8` or `R` for random | | `--data` | `R` | Payload as hex, `R` for random bytes, or `I` for incrementing | -| `--count` | `1` | Number of frames to generate | +| `--count` | continuous | Number of frames to generate; omit to generate continuously until interrupted (Ctrl-C), matching `cangen`. `--dry-run` without `--count` plans a single frame. Invocations that cannot receive Ctrl-C (e.g. the MCP server) must pass `--count` explicitly. | | `--gap` | `200` | Inter-frame gap in milliseconds | | `--extended` | off | Force 29-bit extended arbitration IDs | | `--ack-active` | off | Request an interactive confirmation prompt before generated frames are transmitted | @@ -114,6 +117,7 @@ frames: 3 | `INVALID_FRAME_DATA` | `--data` is not valid hex, `R`, or `I` | 1 | | `INVALID_COUNT` | `--count` is less than `1` | 1 | | `INVALID_GAP` | `--gap` is less than `0` | 1 | +| `MISSING_COUNT` | `--count` was omitted for a live run reached through a non-interactive entry point (e.g. the MCP server) that cannot deliver Ctrl-C | 1 | | `ACTIVE_ACK_REQUIRED` | active acknowledgement is required but `--ack-active` was omitted | 1 | | `ACTIVE_CONFIRMATION_DECLINED` | `--ack-active` was supplied but the confirmation response was not `YES` | 1 | diff --git a/docs/tests/generate-command.md b/docs/tests/generate-command.md index db17807..e816346 100644 --- a/docs/tests/generate-command.md +++ b/docs/tests/generate-command.md @@ -35,6 +35,9 @@ Validate that `generate` produces deterministic frame sequences, emits active-tr | `REQ-GENERATE-07` | `TEST-GENERATE-06` | | `REQ-GENERATE-08` | `TEST-GENERATE-07` | | `REQ-GENERATE-09` | `TEST-GENERATE-08` | +| `REQ-GENERATE-12` | `TEST-GENERATE-09` | +| `REQ-GENERATE-13` | `TEST-GENERATE-10` | +| `REQ-GENERATE-14` | `TEST-GENERATE-11` | ## Representative Test Cases @@ -143,6 +146,46 @@ And `errors[0].code` shall equal `"INVALID_GAP"` --- +### `TEST-GENERATE-09` — Continuous generation without `--count` + +```gherkin +Given the scaffold transport backend is active +When the operator runs `canarchy generate can0 --json` with no `--count` +Then the command shall stream generated frame events continuously +And the run shall stop cleanly with exit code `0` when interrupted (Ctrl-C / SIGINT) +``` + +**Fixture:** scaffold backend; `time.sleep` mocked to raise `KeyboardInterrupt` after a fixed number of frames. + +--- + +### `TEST-GENERATE-10` — Dry run without `--count` plans a single frame + +```gherkin +Given the scaffold transport backend is active +When the operator runs `canarchy generate can0 --dry-run --json` with no `--count` +Then the command shall succeed +And `data.frame_count` shall equal `1` +And no transport transmission shall occur +``` + +**Fixture:** scaffold backend (no file required). + +--- + +### `TEST-GENERATE-11` — Live generation without `--count` through a non-interactive entry point + +```gherkin +Given the MCP server invokes `generate` directly via `execute_command`, bypassing the interactive CLI's Ctrl-C dispatch +When the call requests a live run (`dry_run=false`) without `count` +Then the command shall exit with code `1` +And `errors[0].code` shall equal `"MISSING_COUNT"` +``` + +**Fixture:** none required. + +--- + ## Fixtures And Environment No dedicated fixture files are required. Tests exercise the command through the deterministic scaffold backend and CLI unit coverage. diff --git a/docs/tutorials/generate_and_capture.md b/docs/tutorials/generate_and_capture.md index 8653829..8debd7a 100644 --- a/docs/tutorials/generate_and_capture.md +++ b/docs/tutorials/generate_and_capture.md @@ -104,7 +104,7 @@ Terminal 1 prints: ### Extended (29-bit) IDs ```bash -canarchy generate 239.0.0.1 --id 0x18FEEE31 --dlc 8 --data R --extended +canarchy generate 239.0.0.1 --id 0x18FEEE31 --dlc 8 --data R --extended --count 1 ``` ### Control the gap @@ -115,6 +115,14 @@ canarchy generate 239.0.0.1 --id 0x18FEEE31 --dlc 8 --data R --extended canarchy generate 239.0.0.1 --count 10 --gap 50 ``` +### Run continuously, `cangen`-style + +Omit `--count` to generate and transmit frames continuously, spaced by `--gap`, until you stop it with Ctrl-C: + +```bash +canarchy generate 239.0.0.1 --gap 50 +``` + ## Get Structured Output Use `--json` instead of `--candump` to get machine-readable output: diff --git a/src/canarchy/cli.py b/src/canarchy/cli.py index 276313b..027bed6 100644 --- a/src/canarchy/cli.py +++ b/src/canarchy/cli.py @@ -604,7 +604,12 @@ def build_parser() -> CanarchyArgumentParser: generate.add_argument( "--data", default="R", help="payload hex, R for random, I for incrementing" ) - generate.add_argument("--count", type=int, default=1, help="number of frames to generate") + generate.add_argument( + "--count", + type=int, + default=None, + help="number of frames to generate; omit to generate continuously until interrupted (Ctrl-C)", + ) generate.add_argument( "--gap", type=float, default=200.0, help="inter-frame gap in milliseconds" ) @@ -3054,7 +3059,7 @@ def validate_args(args: argparse.Namespace) -> None: data={"data": args.data}, ) - if args.count < 1: + if args.count is not None and args.count < 1: raise CommandError( command=args.command, exit_code=EXIT_USER_ERROR, @@ -3068,6 +3073,12 @@ def validate_args(args: argparse.Namespace) -> None: data={"count": args.count}, ) + if args.count is None and getattr(args, "dry_run", False): + # Dry-run plans a fixed set of frames to display, so an unbounded + # generation request (the default with no `--count`) assumes a + # single-frame plan rather than requiring `--count` explicitly. + args.count = 1 + if args.gap < 0: raise CommandError( command=args.command, @@ -4577,6 +4588,26 @@ def transport_payload( [], ) if args.command == "generate": + if args.count is None: + # Continuous generation (no `--count`) streams frame-by-frame until + # Ctrl-C from `main()`'s CLI dispatch; callers that reach this + # batch/JSON-envelope path directly (e.g. the MCP server) have no + # way to interrupt an unbounded run, so require an explicit count. + raise CommandError( + command=args.command, + exit_code=EXIT_USER_ERROR, + errors=[ + ErrorDetail( + code="MISSING_COUNT", + message="An explicit `--count` is required for this invocation.", + hint=( + "Pass `--count N`, or run `canarchy generate` from an " + "interactive terminal to stream continuously until Ctrl-C." + ), + ) + ], + data={"count": None}, + ) frames = generate_frames( args.interface, id_spec=args.id, @@ -11425,6 +11456,47 @@ def emit_live_gateway(args: argparse.Namespace) -> int: return EXIT_OK +def emit_live_generate(args: argparse.Namespace, output_format: str) -> int: + """Stream generated frames until Ctrl+C — used when `generate` has no `--count`.""" + transport = LocalTransport() + enforce_active_transmit_safety(args) + text_mode = output_format == "text" + try: + for event in transport.generate_stream_events( + args.interface, + id_spec=args.id, + dlc_spec=args.dlc, + data_spec=args.data, + count=args.count, + gap_ms=args.gap, + extended=args.extended, + ): + if text_mode: + if event.get("event_type") != "frame": + continue + frame = event["payload"]["frame"] + interface = frame["interface"] or args.interface + timestamp = event.get("timestamp") + timestamp_text = ( + f"({timestamp:0.6f})" if isinstance(timestamp, (int, float)) else "(0.000000)" + ) + print(f"{timestamp_text} {interface} {format_candump_frame(frame)}") + else: + print(json.dumps(event, sort_keys=True)) + except TransportError as exc: + emit_result( + error_result( + "generate", + errors=[ErrorDetail(code=exc.code, message=exc.message, hint=exc.hint)], + ), + output_format, + ) + return EXIT_TRANSPORT_ERROR + except KeyboardInterrupt: + return EXIT_OK + return EXIT_OK + + def emit_dataset_stream(args: argparse.Namespace) -> int: from canarchy.dataset_convert import ConversionError, stream_file @@ -12294,6 +12366,19 @@ def main(argv: Sequence[str] | None = None) -> int: and not getattr(args, "dry_run", False) ): return emit_live_gateway(args) + if args.command == "generate" and args.count is None and not getattr(args, "dry_run", False): + # This dispatch bypasses `execute_command`'s batch path (and its + # `validate_args` call), so validate here to catch a bad `--id`, + # `--dlc`, `--data`, or `--gap` before an unbounded stream starts. + try: + validate_args(args) + except CommandError as exc: + emit_result( + error_result(exc.command, errors=exc.errors, data=exc.data, warnings=exc.warnings), + output_format, + ) + return exc.exit_code + return emit_live_generate(args, output_format) if args.command == "datasets stream" and not args.json: return emit_dataset_stream(args) if ( diff --git a/src/canarchy/mcp_server.py b/src/canarchy/mcp_server.py index 2f676fb..a5f7224 100644 --- a/src/canarchy/mcp_server.py +++ b/src/canarchy/mcp_server.py @@ -99,7 +99,11 @@ }, "count": { "type": "integer", - "description": "Number of frames to generate", + "description": ( + "Number of frames to generate. Defaults to 1 for a dry-run plan; " + "required (and must be finite) when `dry_run=false`, since this " + "tool cannot interrupt an unbounded live run." + ), "default": 1, }, "gap": { diff --git a/src/canarchy/transport.py b/src/canarchy/transport.py index 4281ba5..f4f1e5f 100644 --- a/src/canarchy/transport.py +++ b/src/canarchy/transport.py @@ -2,6 +2,7 @@ from __future__ import annotations +import itertools import os import queue import random @@ -988,6 +989,45 @@ def generate_events( events.append(FrameEvent(frame=sent_frame, source="transport.generate").to_event()) return serialize_events(events) + def generate_stream_events( + self, + interface: str, + *, + id_spec: str = "R", + dlc_spec: str = "R", + data_spec: str = "R", + count: int | None = None, + gap_ms: float = 200.0, + extended: bool = False, + ) -> Iterator[dict[str, object]]: + """Generate and transmit frames one at a time, running forever when *count* is ``None``.""" + yield serialize_events( + [ + AlertEvent( + level="warning", + code="ACTIVE_TRANSMIT", + message="Active frame generation requested on the selected interface.", + source="transport.generate", + ).to_event(), + ] + )[0] + frames = iter_generated_frames( + interface, + id_spec=id_spec, + dlc_spec=dlc_spec, + data_spec=data_spec, + count=count, + gap_ms=gap_ms, + extended=extended, + ) + for i, frame in enumerate(frames): + if i > 0 and gap_ms > 0: + time.sleep(gap_ms / 1000.0) + sent_frame = self.send(interface, frame) + yield serialize_events( + [FrameEvent(frame=sent_frame, source="transport.generate").to_event()] + )[0] + def _gateway_unidirectional_stream( self, src: str, @@ -1606,6 +1646,43 @@ def parse_candump_fd_line(match: re.Match[str], *, path: Path, line_number: int) ) from exc +def _build_generated_frame( + interface: str, + index: int, + *, + id_spec: str, + dlc_spec: str, + data_spec: str, + gap_ms: float, + extended: bool, +) -> CanFrame: + if id_spec.upper() == "R": + arb_id = random.randint(0, 0x1FFFFFFF if extended else 0x7FF) + else: + arb_id = int(id_spec, 16) + is_extended = extended or arb_id > 0x7FF + + if dlc_spec.upper() == "R": + dlc = random.randint(0, 8) + else: + dlc = int(dlc_spec) + + if data_spec.upper() == "R": + data = bytes(random.randint(0, 255) for _ in range(dlc)) + elif data_spec.upper() == "I": + data = bytes((index * dlc + j) % 256 for j in range(dlc)) + else: + data = bytes.fromhex(data_spec) + + return CanFrame( + arbitration_id=arb_id, + data=data, + interface=interface, + is_extended_id=is_extended, + timestamp=index * gap_ms / 1000.0, + ) + + def generate_frames( interface: str, *, @@ -1616,33 +1693,39 @@ def generate_frames( gap_ms: float = 200.0, extended: bool = False, ) -> list[CanFrame]: - frames: list[CanFrame] = [] - for i in range(count): - if id_spec.upper() == "R": - arb_id = random.randint(0, 0x1FFFFFFF if extended else 0x7FF) - else: - arb_id = int(id_spec, 16) - is_extended = extended or arb_id > 0x7FF + return [ + _build_generated_frame( + interface, + i, + id_spec=id_spec, + dlc_spec=dlc_spec, + data_spec=data_spec, + gap_ms=gap_ms, + extended=extended, + ) + for i in range(count) + ] - if dlc_spec.upper() == "R": - dlc = random.randint(0, 8) - else: - dlc = int(dlc_spec) - if data_spec.upper() == "R": - data = bytes(random.randint(0, 255) for _ in range(dlc)) - elif data_spec.upper() == "I": - data = bytes((i * dlc + j) % 256 for j in range(dlc)) - else: - data = bytes.fromhex(data_spec) - - frames.append( - CanFrame( - arbitration_id=arb_id, - data=data, - interface=interface, - is_extended_id=is_extended, - timestamp=i * gap_ms / 1000.0, - ) +def iter_generated_frames( + interface: str, + *, + id_spec: str = "R", + dlc_spec: str = "R", + data_spec: str = "R", + count: int | None = None, + gap_ms: float = 200.0, + extended: bool = False, +) -> Iterator[CanFrame]: + """Yield generated frames, running forever when *count* is ``None``.""" + indices: Iterator[int] = itertools.count() if count is None else range(count) + for i in indices: + yield _build_generated_frame( + interface, + i, + id_spec=id_spec, + dlc_spec=dlc_spec, + data_spec=data_spec, + gap_ms=gap_ms, + extended=extended, ) - return frames diff --git a/tests/test_cli.py b/tests/test_cli.py index de6ce53..dede4ec 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4052,7 +4052,17 @@ def test_j1939_pgn_json_includes_decoded_signals(self) -> None: ) def test_generate_fixed_id_and_data_returns_frame_events(self, _mock_cfg) -> None: exit_code, stdout, stderr = run_cli( - "generate", "can0", "--id", "0x123", "--dlc", "4", "--data", "11223344", "--json" + "generate", + "can0", + "--id", + "0x123", + "--dlc", + "4", + "--data", + "11223344", + "--count", + "1", + "--json", ) self.assertEqual(exit_code, EXIT_OK) self.assertIn("warning: `generate` will transmit generated frames", stderr) @@ -4211,7 +4221,18 @@ def test_generate_text_output_is_pretty_printed(self, _mock_cfg, _mock_sleep) -> ) def test_generate_extended_flag_sets_29bit_id(self, _mock_cfg) -> None: exit_code, stdout, stderr = run_cli( - "generate", "can0", "--id", "0x100", "--dlc", "0", "--data", "R", "--extended", "--json" + "generate", + "can0", + "--id", + "0x100", + "--dlc", + "0", + "--data", + "R", + "--extended", + "--count", + "1", + "--json", ) self.assertEqual(exit_code, EXIT_OK) payload = json.loads(stdout) @@ -4226,7 +4247,17 @@ def test_generate_extended_flag_sets_29bit_id(self, _mock_cfg) -> None: ) def test_generate_default_gap_is_200ms(self, _mock_cfg) -> None: exit_code, stdout, _ = run_cli( - "generate", "can0", "--id", "0x1", "--dlc", "1", "--data", "FF", "--json" + "generate", + "can0", + "--id", + "0x1", + "--dlc", + "1", + "--data", + "FF", + "--count", + "1", + "--json", ) self.assertEqual(exit_code, EXIT_OK) payload = json.loads(stdout) @@ -4581,7 +4612,17 @@ def test_stdin_required_when_no_file(self, _mock_cfg): ) def test_generate_id_without_0x_prefix(self, _mock_cfg) -> None: exit_code, stdout, _ = run_cli( - "generate", "can0", "--id", "7DF", "--dlc", "2", "--data", "1234", "--json" + "generate", + "can0", + "--id", + "7DF", + "--dlc", + "2", + "--data", + "1234", + "--count", + "1", + "--json", ) self.assertEqual(exit_code, EXIT_OK) payload = json.loads(stdout) @@ -4617,6 +4658,8 @@ def test_generate_large_id_forces_extended(self, _mock_cfg) -> None: "8", "--data", "AABBCCDDEEFF0011", + "--count", + "1", "--json", ) self.assertEqual(exit_code, EXIT_OK) @@ -4631,7 +4674,17 @@ def test_generate_large_id_forces_extended(self, _mock_cfg) -> None: ) def test_generate_id_zero(self, _mock_cfg) -> None: exit_code, stdout, _ = run_cli( - "generate", "can0", "--id", "0x0", "--dlc", "1", "--data", "FF", "--json" + "generate", + "can0", + "--id", + "0x0", + "--dlc", + "1", + "--data", + "FF", + "--count", + "1", + "--json", ) self.assertEqual(exit_code, EXIT_OK) payload = json.loads(stdout) @@ -4644,7 +4697,17 @@ def test_generate_id_zero(self, _mock_cfg) -> None: ) def test_generate_id_max_standard(self, _mock_cfg) -> None: exit_code, stdout, _ = run_cli( - "generate", "can0", "--id", "0x7FF", "--dlc", "1", "--data", "00", "--json" + "generate", + "can0", + "--id", + "0x7FF", + "--dlc", + "1", + "--data", + "00", + "--count", + "1", + "--json", ) self.assertEqual(exit_code, EXIT_OK) payload = json.loads(stdout) @@ -4660,7 +4723,7 @@ def test_generate_id_max_standard(self, _mock_cfg) -> None: ) def test_generate_dlc_zero_produces_empty_data(self, _mock_cfg) -> None: exit_code, stdout, _ = run_cli( - "generate", "can0", "--id", "0x1", "--dlc", "0", "--data", "R", "--json" + "generate", "can0", "--id", "0x1", "--dlc", "0", "--data", "R", "--count", "1", "--json" ) self.assertEqual(exit_code, EXIT_OK) payload = json.loads(stdout) @@ -4674,7 +4737,7 @@ def test_generate_dlc_zero_produces_empty_data(self, _mock_cfg) -> None: ) def test_generate_dlc_eight_produces_eight_byte_frame(self, _mock_cfg) -> None: exit_code, stdout, _ = run_cli( - "generate", "can0", "--id", "0x1", "--dlc", "8", "--data", "R", "--json" + "generate", "can0", "--id", "0x1", "--dlc", "8", "--data", "R", "--count", "1", "--json" ) self.assertEqual(exit_code, EXIT_OK) payload = json.loads(stdout) @@ -4690,7 +4753,7 @@ def test_generate_dlc_eight_produces_eight_byte_frame(self, _mock_cfg) -> None: ) def test_generate_data_r_produces_hex_payload(self, _mock_cfg) -> None: exit_code, stdout, _ = run_cli( - "generate", "can0", "--id", "0x1", "--dlc", "4", "--data", "R", "--json" + "generate", "can0", "--id", "0x1", "--dlc", "4", "--data", "R", "--count", "1", "--json" ) self.assertEqual(exit_code, EXIT_OK) payload = json.loads(stdout) @@ -4705,7 +4768,17 @@ def test_generate_data_r_produces_hex_payload(self, _mock_cfg) -> None: ) def test_generate_data_uppercase_hex_accepted(self, _mock_cfg) -> None: exit_code, stdout, _ = run_cli( - "generate", "can0", "--id", "0x1", "--dlc", "4", "--data", "DEADBEEF", "--json" + "generate", + "can0", + "--id", + "0x1", + "--dlc", + "4", + "--data", + "DEADBEEF", + "--count", + "1", + "--json", ) self.assertEqual(exit_code, EXIT_OK) payload = json.loads(stdout) @@ -4734,9 +4807,9 @@ def test_generate_incrementing_data_rolls_across_frames(self, _mock_cfg, _mock_s "canarchy.transport._load_user_config", return_value={"CANARCHY_TRANSPORT_BACKEND": "scaffold"}, ) - def test_generate_default_count_is_one(self, _mock_cfg) -> None: + def test_generate_dry_run_default_count_plans_one_frame(self, _mock_cfg) -> None: exit_code, stdout, _ = run_cli( - "generate", "can0", "--id", "0x1", "--dlc", "1", "--data", "FF", "--json" + "generate", "can0", "--id", "0x1", "--dlc", "1", "--data", "FF", "--dry-run", "--json" ) self.assertEqual(exit_code, EXIT_OK) payload = json.loads(stdout) @@ -4744,6 +4817,51 @@ def test_generate_default_count_is_one(self, _mock_cfg) -> None: frame_events = [e for e in payload["data"]["events"] if e["event_type"] == "frame"] self.assertEqual(len(frame_events), 1) + @patch("canarchy.transport.time.sleep") + @patch( + "canarchy.transport._load_user_config", + return_value={"CANARCHY_TRANSPORT_BACKEND": "scaffold"}, + ) + def test_generate_without_count_streams_until_interrupted(self, _mock_cfg, mock_sleep) -> None: + mock_sleep.side_effect = [None, KeyboardInterrupt] + exit_code, stdout, stderr = run_cli( + "generate", "can0", "--id", "0x1", "--dlc", "1", "--data", "FF", "--json" + ) + self.assertEqual(exit_code, EXIT_OK) + self.assertIn("warning: `generate` will transmit generated frames", stderr) + lines = stdout.strip().splitlines() + events = [json.loads(line) for line in lines] + self.assertEqual(events[0]["event_type"], "alert") + self.assertEqual(events[0]["payload"]["code"], "ACTIVE_TRANSMIT") + frame_events = [e for e in events if e["event_type"] == "frame"] + self.assertEqual(len(frame_events), 2) + + @patch( + "canarchy.transport._load_user_config", + return_value={"CANARCHY_TRANSPORT_BACKEND": "scaffold"}, + ) + def test_generate_dry_run_requires_no_explicit_count(self, _mock_cfg) -> None: + with patch.object( + LocalTransport, + "generate_stream_events", + side_effect=AssertionError("generate_stream_events must not be called in dry-run"), + ): + exit_code, stdout, _ = run_cli( + "generate", + "can0", + "--id", + "0x1", + "--dlc", + "1", + "--data", + "FF", + "--dry-run", + "--json", + ) + self.assertEqual(exit_code, EXIT_OK) + payload = json.loads(stdout) + self.assertEqual(payload["data"]["mode"], "dry_run") + @patch("canarchy.transport.time.sleep") @patch( "canarchy.transport._load_user_config", diff --git a/tests/test_mcp.py b/tests/test_mcp.py index d7e4a6c..aead8f3 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1418,6 +1418,21 @@ def test_generate_with_ack_active_defaults_to_dry_run(): assert payload["data"]["frame_count"] == 1 +def test_generate_live_without_count_returns_structured_error(): + # The MCP tool call is one-shot request/response and has no way to send + # Ctrl-C, so an unbounded live `generate` (no `count`) must be rejected + # rather than hang the call forever. + results = asyncio.run( + handle_call_tool( + "generate", + {"interface": "can0", "ack_active": True, "dry_run": False}, + ) + ) + payload = json.loads(results[0].text) + assert payload["ok"] is False + assert payload["errors"][0]["code"] == "MISSING_COUNT" + + def test_simulate_without_ack_active_returns_structured_error(): results = asyncio.run( handle_call_tool("simulate", {"profile": "heavy-truck", "interface": "vcan0"}) diff --git a/tests/test_transport.py b/tests/test_transport.py index 6ba20cc..95ccb9a 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -2,6 +2,7 @@ import contextlib import io +import itertools import json import multiprocessing import os @@ -26,6 +27,7 @@ build_live_backend, capture_metadata, iter_candump_file, + iter_generated_frames, load_candump_file, parse_candump_line, python_can, @@ -555,6 +557,20 @@ def test_python_can_send_encodes_live_frames(self) -> None: ) self.assertTrue(fake_bus.shutdown_called) + def test_iter_generated_frames_with_count_is_finite(self) -> None: + frames = list( + iter_generated_frames("can0", id_spec="0x100", dlc_spec="1", data_spec="00", count=3) + ) + self.assertEqual(len(frames), 3) + + def test_iter_generated_frames_without_count_is_unbounded(self) -> None: + frames = iter_generated_frames( + "can0", id_spec="0x100", dlc_spec="1", data_spec="00", count=None + ) + first_ten = list(itertools.islice(frames, 10)) + self.assertEqual(len(first_ten), 10) + self.assertTrue(all(f.arbitration_id == 0x100 for f in first_ten)) + @unittest.skipIf(python_can is None, "python-can is not installed") def test_generate_frames_round_trip_in_same_process(self) -> None: """generate_events delivers all frames to a concurrent capture in the same process."""