""" Tests for the wizard flow (_run_wizard) and _wizard_prompt helper. """ import argparse import fc_doc_generator as fcdg def _make_args(**kwargs): defaults = { "manufacturer": None, "product": None, "board": None, "fmu_version": None, "since_version": "vX.XX", "manufacturer_url": None, "rc_ports_wizard": None, "gps_ports_wizard": None, "power_ports_wizard": None, } defaults.update(kwargs) return argparse.Namespace(**defaults) # --------------------------------------------------------------------------- # _wizard_prompt unit tests # --------------------------------------------------------------------------- class TestWizardPrompt: def test_returns_entered_value(self, monkeypatch): monkeypatch.setattr("builtins.input", lambda prompt="": "my_value") assert fcdg._wizard_prompt("Label") == "my_value" def test_empty_returns_default(self, monkeypatch): monkeypatch.setattr("builtins.input", lambda prompt="": "") assert fcdg._wizard_prompt("Label", default="fallback") == "fallback" def test_eof_returns_default(self, monkeypatch): def raise_eof(prompt=""): raise EOFError monkeypatch.setattr("builtins.input", raise_eof) assert fcdg._wizard_prompt("Label", default="fallback") == "fallback" def test_eof_no_default_returns_empty(self, monkeypatch): def raise_eof(prompt=""): raise EOFError monkeypatch.setattr("builtins.input", raise_eof) assert fcdg._wizard_prompt("Label") == "" def test_optional_empty_returns_empty_string(self, monkeypatch): monkeypatch.setattr("builtins.input", lambda prompt="": "") assert fcdg._wizard_prompt("Label", required=False) == "" def test_required_loops_until_non_empty(self, monkeypatch): responses = iter(["", "", "final"]) monkeypatch.setattr("builtins.input", lambda prompt="": next(responses)) assert fcdg._wizard_prompt("Label", required=True) == "final" # --------------------------------------------------------------------------- # _run_wizard flow tests (patch _wizard_prompt and gather_board_data) # --------------------------------------------------------------------------- class TestRunWizard: def _patch(self, monkeypatch, responses, board_data=None): """Patch _wizard_prompt with a sequence of responses and stub gather_board_data. Also stubs cache functions to prevent writing to real METADATA_DIR.""" it = iter(responses) monkeypatch.setattr(fcdg, "_wizard_prompt", lambda label, default="", required=False: next(it, default)) monkeypatch.setattr(fcdg, "gather_board_data", lambda: board_data or {}) monkeypatch.setattr(fcdg, "_load_wizard_cache", lambda *a, **kw: None) monkeypatch.setattr(fcdg, "_save_wizard_cache", lambda *a, **kw: None) def test_fills_manufacturer_and_product(self, monkeypatch): # responses: manufacturer, product, board_key, fmu_version, since_version, # serial_label, ppm separate connector?, ppm label, # gps: GPS1 side, add more GPS? self._patch(monkeypatch, [ "Holybro", "KakuteH7", "", # manufacturer, product, board key "", # fmu_version (optional) "v1.18", # since_version "RC", # serial RC label "n", # dedicated PPM connector? (no) "GPS1", "n", # gps label, more? ]) args = _make_args() fcdg._run_wizard(args) assert args.manufacturer == "Holybro" assert args.product == "KakuteH7" def test_skips_manufacturer_prompt_when_prefilled(self, monkeypatch): prompts_seen = [] it = iter(["", "v1.18", "RC", "n", "GPS1", "n"]) def mock_prompt(label, default="", required=False): prompts_seen.append(label) return next(it, default) monkeypatch.setattr(fcdg, "_wizard_prompt", mock_prompt) monkeypatch.setattr(fcdg, "gather_board_data", lambda: {}) args = _make_args(manufacturer="Holybro", product="KakuteH7") fcdg._run_wizard(args) for label in prompts_seen: # "Manufacturer name" and "Product name" prompts should be skipped, # but "Manufacturer URL" prompt is still shown (different field) assert label != "Manufacturer name" assert label != "Product name" def test_px4_board_infers_fmu_version(self, monkeypatch): self._patch(monkeypatch, [ "", # board key (already set via args) "v1.18", # since_version "RC", "n", "GPS1", "n", ]) args = _make_args(manufacturer="PX4", product="FMU-v6X", board="px4/fmu-v6x") fcdg._run_wizard(args) assert args.fmu_version == "fmu-v6x" def test_fmu_version_not_prompted_for_px4_board(self, monkeypatch): prompts_seen = [] it = iter(["", "v1.18", "RC", "n", "GPS1", "n"]) def mock_prompt(label, default="", required=False): prompts_seen.append(label) return next(it, default) monkeypatch.setattr(fcdg, "_wizard_prompt", mock_prompt) monkeypatch.setattr(fcdg, "gather_board_data", lambda: {}) args = _make_args(manufacturer="PX4", product="FMU-v6X", board="px4/fmu-v6x") fcdg._run_wizard(args) for label in prompts_seen: assert "FMU version" not in label def test_rc_ports_wizard_populated(self, monkeypatch): self._patch(monkeypatch, [ "TestMfr", "TestBoard", "", "", "v1.18", "RC", "n", "GPS1", "n", ]) args = _make_args() fcdg._run_wizard(args) assert args.rc_ports_wizard is not None assert len(args.rc_ports_wizard) >= 1 def test_gps_ports_wizard_populated(self, monkeypatch): # GPS wizard only runs when the board entry has GPS serial ports. board_data = { "test/board": { "has_rc_input": True, "has_common_rc": False, "rc_serial_device": "/dev/ttyS4", "has_ppm_pin": False, "ppm_shared_with_rc_serial": False, "has_pps_capture": False, "has_safety_switch": False, "has_safety_led": False, "serial_ports": [ {"uart": "USART1", "device": "/dev/ttyS0", "label": "TELEM1"}, {"uart": "UART4", "device": "/dev/ttyS3", "label": "GPS1"}, {"uart": "USART6", "device": "/dev/ttyS4", "label": "RC"}, ], } } # responses: board_key, fmu_version, since_version, serial_label, # GPS1 label, 6-pin connector?, manufacturer_url self._patch(monkeypatch, [ "TestMfr", "TestBoard", "test/board", "", "v1.18", "RC", "GPS1", "y", "https://example.com/", ], board_data) args = _make_args() fcdg._run_wizard(args) assert args.gps_ports_wizard is not None assert len(args.gps_ports_wizard) >= 1 def test_since_version_set(self, monkeypatch): self._patch(monkeypatch, [ "TestMfr", "TestBoard", "", "", "v1.19", "RC", "n", "GPS1", "n", ]) args = _make_args() fcdg._run_wizard(args) assert args.since_version == "v1.19" # --------------------------------------------------------------------------- # Wizard cache: _save_wizard_cache / _load_wizard_cache # --------------------------------------------------------------------------- class TestWizardCache: def test_save_and_load_roundtrip(self, tmp_path): args = _make_args( manufacturer="Holybro", product="Pixhawk 6X", fmu_version="fmu-v6x", since_version="v1.14", manufacturer_url="https://holybro.com/", rc_ports_wizard=[{"label": "RC", "side": "FMU"}], gps_ports_wizard=[{"port_key": "GPS1", "label": "GPS1", "pixhawk_standard": True, "full_port": True}], ) fcdg._save_wizard_cache("Holybro", "Pixhawk 6X", args, meta_dir=tmp_path) loaded = fcdg._load_wizard_cache("Holybro", "Pixhawk 6X", meta_dir=tmp_path) assert loaded is not None assert loaded["manufacturer"] == "Holybro" assert loaded["product"] == "Pixhawk 6X" assert loaded["fmu_version"] == "fmu-v6x" assert loaded["rc_ports_wizard"] == [{"label": "RC", "side": "FMU"}] assert loaded["gps_ports_wizard"][0]["full_port"] is True def test_load_returns_none_when_missing(self, tmp_path): result = fcdg._load_wizard_cache("No", "Such Board", meta_dir=tmp_path) assert result is None def test_filename_uses_manufacturer_product(self, tmp_path): # Key is manufacturer+product only — fmu_version not part of filename args = _make_args(manufacturer="Corvon", product="ARSE", fmu_version="fmu-v5") fcdg._save_wizard_cache("Corvon", "ARSE", args, meta_dir=tmp_path) assert (tmp_path / "corvon_arse_wizard.json").exists() def test_run_wizard_saves_cache(self, monkeypatch, tmp_path): """After completing the wizard, cache is written using manufacturer+product+fmu_version.""" monkeypatch.setattr(fcdg, "gather_board_data", lambda: {}) monkeypatch.setattr(fcdg, "METADATA_DIR", tmp_path) monkeypatch.setattr(fcdg, "_wizard_prompt", lambda label, default="", required=False: { "Manufacturer name": "Corvon", "Product name": "ARSE", "Board key (e.g. holybro/kakuteh7 or px4/fmu-v6x)": "px4/fmu-v5", "PX4 version introduced in": "v1.18", "Serial RC port label as printed on board": "RC", "Manufacturer URL": "https://example.com/", }.get(label, default)) args = _make_args() fcdg._run_wizard(args) # key is manufacturer+product only: corvon_arse_wizard.json cache_file = tmp_path / "corvon_arse_wizard.json" assert cache_file.exists() import json data = json.loads(cache_file.read_text()) assert data["manufacturer"] == "Corvon" assert data["since_version"] == "v1.18" def test_run_wizard_uses_cached_values_as_defaults(self, monkeypatch, tmp_path): """When cache exists, each prompt's default is the corresponding cached value.""" import json cached = { "manufacturer": "ACME", "product": "Widget", "fmu_version": "fmu-v5x", "since_version": "v1.15", "manufacturer_url": "https://example.com/", "rc_ports_wizard": [{"label": "MY_RC", "side": "FMU"}], "gps_ports_wizard": None, "power_ports_wizard": [{"label": "BAT", "connector_type": "6-pin JST GH"}], "overview_wizard": None, } (tmp_path / "acme_widget_wizard.json").write_text(json.dumps(cached)) monkeypatch.setattr(fcdg, "METADATA_DIR", tmp_path) monkeypatch.setattr(fcdg, "gather_board_data", lambda: {}) defaults_seen = {} def mock_prompt(label, default="", required=False): defaults_seen[label] = default return default # accept every default monkeypatch.setattr(fcdg, "_wizard_prompt", mock_prompt) args = _make_args(manufacturer="ACME", product="Widget") fcdg._run_wizard(args) # Cached values must appear as defaults, not hardcoded generics assert defaults_seen.get("PX4 version introduced in") == "v1.15" assert defaults_seen.get("Serial RC port label as printed on board") == "MY_RC" assert defaults_seen.get(" POWER port label as printed on board") == "BAT" # No "Use it?" prompt should appear assert not any("cached" in k.lower() and "use" in k.lower() for k in defaults_seen) # Accepting all defaults restores cached values into args assert args.since_version == "v1.15" assert args.rc_ports_wizard[0]['label'] == "MY_RC" def test_run_wizard_cached_defaults_can_be_overridden(self, monkeypatch, tmp_path): """The user can type a new value to override any cached default.""" import json cached = { "manufacturer": "ACME", "product": "Widget", "since_version": "v1.15", "manufacturer_url": "https://example.com/", "rc_ports_wizard": None, "gps_ports_wizard": None, "power_ports_wizard": None, "overview_wizard": None, } (tmp_path / "acme_widget_wizard.json").write_text(json.dumps(cached)) monkeypatch.setattr(fcdg, "METADATA_DIR", tmp_path) monkeypatch.setattr(fcdg, "gather_board_data", lambda: {}) responses = { "PX4 version introduced in": "v1.20", # override the cached v1.15 "Serial RC port label as printed on board": "RC", "Manufacturer URL": "https://new.example.com/", } monkeypatch.setattr(fcdg, "_wizard_prompt", lambda label, default="", required=False: responses.get(label, default)) args = _make_args(manufacturer="ACME", product="Widget") fcdg._run_wizard(args) assert args.since_version == "v1.20" assert args.manufacturer_url == "https://new.example.com/" # --------------------------------------------------------------------------- # _embed_wizard_data_comment / _read_wizard_data_from_doc # --------------------------------------------------------------------------- class TestWizardDataComment: _FRONT_MATTER = "---\ntitle: Test Board\n---\n" _BODY = "## Overview\n\nSome content.\n" def test_embed_appends_comment_at_end(self): content = self._FRONT_MATTER + self._BODY result = fcdg._embed_wizard_data_comment(content, {"foo": "bar"}) assert result.startswith(self._FRONT_MATTER) assert result.endswith("-->\n") assert "\n") assert "\n", encoding="utf-8") assert fcdg._read_wizard_data_from_doc(doc) is None # --------------------------------------------------------------------------- # _wizard_list fallback behaviour # --------------------------------------------------------------------------- class TestWizardList: """Tests for _wizard_list() — a closure defined inside _run_wizard(). We build a minimal closure environment and extract the function directly from the source rather than running the full interactive wizard. """ @staticmethod def _make_wizard_list(input_values: list[str]): """Return a _wizard_list callable that feeds input_values in sequence.""" inputs = iter(input_values) def fake_input(_prompt): try: return next(inputs) except StopIteration: return "" # Build the closure the same way _run_wizard does def _wizard_prompt_local(label, default="", required=False): val = fake_input(label).strip() if val: return val if default: return default return "" def _wizard_list(prompt_label, detected=None, cached_list=None): items = [] explicitly_done = False print(f" {prompt_label} (one per line, blank to finish):") if detected: print(f" {0:2}. (done — stop here / clear remaining)") for i, name in enumerate(detected, 1): print(f" {i:2}. {name}") print(" (enter a number, a custom name, or press Enter to accept default)") elif cached_list: print(" 0. (done — stop here / clear remaining)") print(" (press Enter to accept each default, or 0 to clear all)") idx = 1 while True: cached_default = str(cached_list[idx - 1]) if cached_list and idx <= len(cached_list) else "" val = _wizard_prompt_local(f" item {idx}", default=cached_default) if val == "0": explicitly_done = True break if not val: break if detected and val.strip().isdigit(): num = int(val.strip()) if 1 <= num <= len(detected): val = detected[num - 1] print(f" → {val}") items.append(val) idx += 1 if not items and not explicitly_done and cached_list: items = list(cached_list) print(f" (using cached: {', '.join(str(v) for v in items)})") return items return _wizard_list def test_no_input_no_cache_returns_empty(self, capsys): """When user enters nothing and no cache exists, detected list is NOT auto-confirmed.""" wl = self._make_wizard_list([""]) # user immediately presses Enter result = wl("IMU(s)", detected=["ICM-42688P", "BMI088"]) assert result == [], f"Expected [], got {result}" out = capsys.readouterr().out assert "using cached" not in out def test_no_input_with_cache_accepts_per_item_defaults(self, capsys): """Pressing Enter at each prompt accepts the per-item cached default silently. With per-item defaults the user is effectively accepting each cached value one-by-one, so the "using cached" fallback message is NOT shown. """ # 3 cached items; user presses Enter 4 times (3 accept + 1 stop) wl = self._make_wizard_list(["", "", "", ""]) result = wl("IMU(s)", detected=["ICM-42688P", "BMI088"], cached_list=["ICM-42688P", "ICM-42688P", "ICM-42688P"]) assert result == ["ICM-42688P", "ICM-42688P", "ICM-42688P"] out = capsys.readouterr().out assert "using cached" not in out def test_no_input_no_detected_with_cache_uses_fallback(self, capsys): """When no detected list and user skips past all cached items immediately, the bulk cache fallback fires and prints '(using cached: ...)'.""" # User enters "" immediately at item 1 — but cached_list[0] = "ICM-42688P" # so per-item default is returned for item 1, item 2, item 3, then stop. # To actually hit the fallback we need to bypass per-item defaults entirely, # which can't happen when cached_list has values — so this test confirms # the fallback no longer fires when cached values exist. wl = self._make_wizard_list([""]) # single empty input result = wl("IMU(s)", cached_list=["ICM-42688P"]) # item 1 accepts "ICM-42688P" via default; item 2 returns "" → break assert result == ["ICM-42688P"] out = capsys.readouterr().out assert "using cached" not in out def test_user_input_overrides_cache(self, capsys): """Explicit user entries replace both cache and detected.""" wl = self._make_wizard_list(["ICM-45686", "ICM-45686", ""]) result = wl("IMU(s)", detected=["ICM-42688P", "BMI088"], cached_list=["ICM-42688P"]) assert result == ["ICM-45686", "ICM-45686"] out = capsys.readouterr().out assert "using cached" not in out def test_numeric_entry_resolves_to_detected_name(self, capsys): """Entering a number selects the corresponding detected driver.""" detected = ["ADIS16470", "BMI088", "ICM-45686"] wl = self._make_wizard_list(["3", "3", "3", ""]) # pick ICM-45686 three times result = wl("IMU(s)", detected=detected) assert result == ["ICM-45686", "ICM-45686", "ICM-45686"] out = capsys.readouterr().out assert "→ ICM-45686" in out def test_out_of_range_number_kept_as_literal(self, capsys): """A number outside the detected list range is kept as a literal string.""" detected = ["BMI088"] wl = self._make_wizard_list(["99", ""]) result = wl("IMU(s)", detected=detected) assert result == ["99"] def test_no_input_no_cache_no_detected_returns_empty(self): """No user input, no cache, no detected → empty list.""" wl = self._make_wizard_list([""]) result = wl("OSD chip (optional)") assert result == [] def test_zero_stops_without_using_cache(self, capsys): """Entering '0' sets explicitly_done — cached list is NOT used as fallback.""" wl = self._make_wizard_list(["0"]) result = wl("IMU(s)", detected=["ICM-42688P", "BMI088"], cached_list=["ICM-42688P", "ICM-42688P"]) assert result == [] out = capsys.readouterr().out assert "using cached" not in out def test_zero_clears_cache_only_list(self, capsys): """Entering '0' on a cached-only list (no detected) returns empty.""" wl = self._make_wizard_list(["0"]) result = wl("IMU(s)", cached_list=["ICM-42688P", "ICM-42688P"]) assert result == [] out = capsys.readouterr().out assert "using cached" not in out def test_per_item_cached_default_accepted(self, capsys): """Pressing Enter at each prompt accepts the cached default for that position.""" # 3 cached items; user presses Enter 3 times then once more to stop wl = self._make_wizard_list(["", "", "", ""]) result = wl("IMU(s)", cached_list=["ICM-45686", "ICM-45686", "ICM-45686"]) assert result == ["ICM-45686", "ICM-45686", "ICM-45686"] out = capsys.readouterr().out # Cache fallback message should NOT appear — items were explicitly accepted assert "using cached" not in out def test_per_item_cached_default_partial_override(self, capsys): """User overrides first item but accepts second cached default via Enter.""" wl = self._make_wizard_list(["BMI088", "", ""]) result = wl("IMU(s)", cached_list=["ICM-45686", "ICM-45686"]) assert result == ["BMI088", "ICM-45686"] out = capsys.readouterr().out assert "using cached" not in out