项目文件夹

文件
2026-07-13 13:12:33 +08:00

481 行
18 KiB
Python

"""Tests for onboarding next-step guidance."""
from __future__ import annotations
def test_next_steps_uses_powershell_env_hint_on_windows(monkeypatch):
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding import next_steps
cfg = GatewayConfig()
cfg.llm.api_key = ""
cfg.llm.api_key_env = "OPENROUTER_API_KEY"
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.setattr(next_steps.platform, "system", lambda: "Windows")
text = next_steps.format_next_steps(cfg, config_path="C:/tmp/config.toml")
assert 'PowerShell: $env:OPENROUTER_API_KEY = "<your-key>"' in text
assert "$OPENROUTER_API_KEY=<your-key>" not in text
def test_onboarding_finish_output_separates_summary_from_commands():
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding.next_steps import format_next_steps
text = format_next_steps(GatewayConfig(), config_path="C:/tmp/config.toml")
assert text.startswith("Configuration summary:")
assert "Next steps:" not in text
assert "Commands:" in text
assert " Run gateway now: opensquilla gateway run" in text
assert " Start gateway in background: opensquilla gateway start --json" in text
assert " Restart running gateway: opensquilla gateway restart --json" in text
assert "Reference:" in text
assert " Web UI: http://127.0.0.1:18791/control/setup" in text
assert "uv run" not in text
def test_onboarding_finish_output_summarizes_all_capability_sections():
from opensquilla.gateway.config import GatewayConfig, LlmProviderConfig
from opensquilla.onboarding.next_steps import format_next_steps
cfg = GatewayConfig()
cfg.llm = LlmProviderConfig(
provider="deepseek",
model="deepseek-chat",
api_key="sk-test",
base_url="https://api.deepseek.com/v1",
)
cfg.image_generation.enabled = True
cfg.image_generation.primary = "openai/gpt-image-1"
cfg.image_generation.providers.openai.api_key = ""
cfg.memory.embedding.provider = "openai"
cfg.memory.embedding.remote.api_key = ""
text = format_next_steps(cfg, config_path="/tmp/opensquilla/custom.toml")
assert (
" Capabilities: Web search=Ready | Channels=Later | "
"Image generation=Needs action | Voice audio=Later | "
"Memory embedding=Needs action"
) in text
assert text.index(" Capabilities:") < text.index("Commands:")
def test_onboarding_finish_output_uses_product_router_label():
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding.next_steps import format_next_steps
text = format_next_steps(GatewayConfig(), config_path="/tmp/opensquilla/custom.toml")
assert " Router: SquillaRouter, default=c1" in text
assert "profile=openrouter-mix" not in text
def test_onboarding_finish_output_keeps_explicit_config_in_gateway_commands():
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding.next_steps import format_next_steps
text = format_next_steps(GatewayConfig(), config_path="/tmp/opensquilla/custom.toml")
assert (
" Run gateway now: opensquilla gateway run --config /tmp/opensquilla/custom.toml"
in text
)
assert (
" Start gateway in background: "
"opensquilla gateway start --json --config /tmp/opensquilla/custom.toml"
) in text
assert (
" Restart running gateway: "
"opensquilla gateway restart --json --config /tmp/opensquilla/custom.toml"
) in text
def test_onboarding_finish_output_uses_configured_web_setup_url():
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding.next_steps import format_next_steps
cfg = GatewayConfig(port=19999)
cfg.control_ui.base_path = "/ops"
text = format_next_steps(cfg)
assert " Web UI: http://127.0.0.1:19999/ops/setup" in text
assert " Web UI: http://127.0.0.1:18791/control/" not in text
def test_onboarding_finish_output_puts_missing_env_hint_in_commands(monkeypatch):
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding import next_steps
cfg = GatewayConfig()
cfg.llm.api_key = ""
cfg.llm.api_key_env = "OPENROUTER_API_KEY"
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
text = next_steps.format_next_steps(cfg, config_path="C:/tmp/config.toml")
commands = text.split("Commands:", 1)[1].split("Reference:", 1)[0]
reference = text.split("Reference:", 1)[1]
env_hint = next_steps._set_env_hint("OPENROUTER_API_KEY")
assert f"Set key before starting gateway: {env_hint}" in commands
assert "Set key before starting gateway" not in reference
def test_onboarding_finish_output_keeps_provider_key_url_as_reference():
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding.next_steps import format_next_steps
cfg = GatewayConfig()
cfg.llm.provider = "openrouter"
text = format_next_steps(cfg, config_path="C:/tmp/config.toml")
assert "Reference:" in text
assert " Provider keys: https://openrouter.ai/keys" in text
def test_env_reference_warnings_cover_llm_and_search_missing_env(monkeypatch):
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding.next_steps import env_reference_warnings
cfg = GatewayConfig()
cfg.llm.api_key = ""
cfg.llm.api_key_env = "OPENROUTER_API_KEY"
cfg.search_provider = "brave"
cfg.search_api_key = ""
cfg.search_api_key_env = "BRAVE_SEARCH_API_KEY"
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
warnings = env_reference_warnings(cfg)
assert any(
"LLM provider" in warning and "OPENROUTER_API_KEY" in warning
for warning in warnings
)
assert any(
"Search provider" in warning and "BRAVE_SEARCH_API_KEY" in warning
for warning in warnings
)
def test_env_reference_warnings_cover_image_and_memory_missing_env(monkeypatch):
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding.next_steps import env_reference_warnings
cfg = GatewayConfig()
cfg.image_generation.enabled = True
cfg.image_generation.primary = "openrouter/google/gemini-3.1-flash-image-preview"
cfg.image_generation.providers.openrouter.api_key = ""
cfg.image_generation.providers.openrouter.api_key_env = "OPENSQUILLA_IMAGE_KEY"
cfg.memory.embedding.provider = "openai"
cfg.memory.embedding.remote.api_key = ""
cfg.memory.embedding.remote.api_key_env = "OPENAI_EMBEDDINGS_API_KEY"
monkeypatch.delenv("OPENSQUILLA_IMAGE_KEY", raising=False)
monkeypatch.delenv("OPENAI_EMBEDDINGS_API_KEY", raising=False)
warnings = env_reference_warnings(cfg)
assert any(
"Image generation provider" in warning and "OPENSQUILLA_IMAGE_KEY" in warning
for warning in warnings
)
assert any(
"Memory embedding" in warning and "OPENAI_EMBEDDINGS_API_KEY" in warning
for warning in warnings
)
def test_env_reference_warnings_do_not_warn_for_image_generation_missing_env_when_disabled(
monkeypatch,
):
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding.next_steps import env_reference_warnings
cfg = GatewayConfig()
cfg.image_generation.enabled = False
cfg.image_generation.providers.openrouter.api_key = ""
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
warnings = env_reference_warnings(cfg)
assert not any("Image generation" in warning for warning in warnings)
def test_headless_setup_commands_cover_the_ensemble_section():
from opensquilla.onboarding.next_steps import headless_setup_commands
for section in ("ensemble", "llm-ensemble", "llm_ensemble"):
commands = headless_setup_commands(section)
assert commands == [
(
"Headless ensemble",
"opensquilla onboard configure ensemble --enabled",
)
]
def test_quote_cli_arg_uses_posix_quoting_off_windows(monkeypatch):
import shlex
from opensquilla.onboarding import next_steps
monkeypatch.setattr(next_steps.platform, "system", lambda: "Linux")
assert next_steps.quote_cli_arg("/tmp/plain.toml") == "/tmp/plain.toml"
assert next_steps.quote_cli_arg("/tmp/with space.toml") == shlex.quote(
"/tmp/with space.toml"
)
assert next_steps.quote_cli_arg("/tmp/it's.toml") == shlex.quote("/tmp/it's.toml")
def test_quote_cli_arg_uses_powershell_quoting_on_windows(monkeypatch):
from opensquilla.onboarding import next_steps
monkeypatch.setattr(next_steps.platform, "system", lambda: "Windows")
# Plain values stay copyable as-is.
assert next_steps.quote_cli_arg("config.toml") == "config.toml"
# Values needing quotes get PowerShell literal strings, not the POSIX
# '"'"' dance that PowerShell cannot parse.
assert (
next_steps.quote_cli_arg("C:\\Setup Files\\config.toml")
== "'C:\\Setup Files\\config.toml'"
)
assert next_steps.quote_cli_arg("C:\\it's.toml") == "'C:\\it''s.toml'"
assert '"\'"' not in next_steps.quote_cli_arg("C:\\it's.toml")
# PowerShell also treats Unicode smart quotes as single-quote delimiters,
# so they must be doubled as well or the literal terminates early.
assert (
next_steps.quote_cli_arg("C:\\Users\\O’Brien\\config.toml")
== "'C:\\Users\\O’’Brien\\config.toml'"
)
def test_config_cli_arg_is_powershell_safe_on_windows(monkeypatch):
from opensquilla.onboarding import next_steps
monkeypatch.setattr(next_steps.platform, "system", lambda: "Windows")
arg = next_steps._config_cli_arg("C:\\Setup Files\\config.toml")
assert arg == " --config 'C:\\Setup Files\\config.toml'"
def test_set_env_command_is_bare_on_both_platforms(monkeypatch):
from opensquilla.onboarding import next_steps
monkeypatch.setattr(next_steps.platform, "system", lambda: "Linux")
assert next_steps.set_env_command("DUMMY_KEY") == 'export DUMMY_KEY="<your-key>"'
assert next_steps.set_env_hint("DUMMY_KEY") == 'export DUMMY_KEY="<your-key>"'
monkeypatch.setattr(next_steps.platform, "system", lambda: "Windows")
assert next_steps.set_env_command("DUMMY_KEY") == '$env:DUMMY_KEY = "<your-key>"'
# The human hint keeps the shell label; the bare command never carries it.
assert (
next_steps.set_env_hint("DUMMY_KEY")
== 'PowerShell: $env:DUMMY_KEY = "<your-key>"'
)
def test_human_env_command_labels_only_windows(monkeypatch):
from opensquilla.onboarding import next_steps
command = '$env:DUMMY_KEY = "<your-key>"'
monkeypatch.setattr(next_steps.platform, "system", lambda: "Linux")
assert next_steps.human_env_command(command) == command
monkeypatch.setattr(next_steps.platform, "system", lambda: "Windows")
assert next_steps.human_env_command(command) == f"PowerShell: {command}"
def test_env_recovery_commands_carry_only_the_command_on_windows(monkeypatch):
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding import next_steps
from opensquilla.onboarding.status import get_onboarding_status
cfg = GatewayConfig()
cfg.llm.api_key = ""
cfg.llm.api_key_env = "DUMMY_UNSET_LLM_KEY"
monkeypatch.delenv("DUMMY_UNSET_LLM_KEY", raising=False)
monkeypatch.setattr(next_steps.platform, "system", lambda: "Windows")
commands = next_steps.env_recovery_commands(get_onboarding_status(cfg))
assert commands
for entry in commands:
assert set(entry) == {"section", "label", "command"}
assert entry["command"] == '$env:DUMMY_UNSET_LLM_KEY = "<your-key>"'
assert "PowerShell" not in entry["command"]
def test_status_display_words_have_a_single_source_of_truth():
from opensquilla.cli import onboard_cmd
from opensquilla.onboarding import next_steps
from opensquilla.onboarding.section_status import SECTION_STATUS_DISPLAY
# The status table renders through the shared mapping object itself…
assert onboard_cmd._STATUS_DISPLAY is SECTION_STATUS_DISPLAY
# …and the capability summary uses a mechanically derived string view.
assert next_steps._CAPABILITY_STATUS_DISPLAY == {
status.value: display for status, display in SECTION_STATUS_DISPLAY.items()
}
assert set(next_steps._CAPABILITY_STATUS_DISPLAY) == {
"ok", "optional", "missing", "degraded", "unknown",
}
def test_next_steps_mention_persistent_env_file_alongside_export_hint(
tmp_path, monkeypatch
):
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding import next_steps
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "state-home"))
cfg = GatewayConfig()
cfg.llm.api_key = ""
cfg.llm.api_key_env = "DUMMY_UNSET_LLM_KEY"
monkeypatch.delenv("DUMMY_UNSET_LLM_KEY", raising=False)
text = next_steps.format_next_steps(cfg, config_path="/tmp/config.toml")
env_file = str(tmp_path / "state-home" / ".env")
assert (
f"Persist key across restarts: add DUMMY_UNSET_LLM_KEY=<your-key> to {env_file}"
in text
)
# The persist hint sits with the export hint in the Commands block.
commands = text.split("Commands:", 1)[1].split("Reference:", 1)[0]
assert "Set key before starting gateway:" in commands
assert "Persist key across restarts:" in commands
def test_missing_env_warning_mentions_persistent_env_file(tmp_path, monkeypatch):
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding import next_steps
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "state-home"))
cfg = GatewayConfig()
cfg.llm.api_key = ""
cfg.llm.api_key_env = "DUMMY_UNSET_LLM_KEY"
monkeypatch.delenv("DUMMY_UNSET_LLM_KEY", raising=False)
warnings = next_steps.env_reference_warnings(cfg)
env_file = str(tmp_path / "state-home" / ".env")
assert any(
"DUMMY_UNSET_LLM_KEY=<your-key>" in warning and env_file in warning
for warning in warnings
)
def test_next_steps_advertise_the_configure_hub(monkeypatch):
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding import next_steps
cfg = GatewayConfig()
text = next_steps.format_next_steps(cfg, config_path="/tmp/config.toml")
commands = text.split("Commands:", 1)[1].split("Reference:", 1)[0]
assert (
"Change settings anytime: opensquilla onboard configure --config /tmp/config.toml"
in commands
)
def test_fix_next_names_the_exact_command_for_a_degraded_capability(monkeypatch):
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding import next_steps
cfg = GatewayConfig()
cfg.search_provider = "tavily"
cfg.search_api_key = ""
cfg.search_api_key_env = "DUMMY_UNSET_SEARCH_KEY"
monkeypatch.delenv("DUMMY_UNSET_SEARCH_KEY", raising=False)
text = next_steps.format_next_steps(cfg, config_path="/tmp/config.toml")
assert "Fix next:" in text
fix_block = text.split("Fix next:", 1)[1].split("Reference:", 1)[0]
assert "opensquilla onboard configure search --config /tmp/config.toml" in fix_block
def test_fix_next_absent_when_nothing_needs_attention():
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding import next_steps
# Fresh defaults: every capability is either ready or a deliberate
# "Later" opt-out — nothing to nag about.
text = next_steps.format_next_steps(GatewayConfig(), config_path="/tmp/config.toml")
assert "Fix next:" not in text
def test_fix_next_never_advertises_the_nonexistent_configure_audio(monkeypatch):
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding import next_steps
cfg = GatewayConfig()
cfg.audio.enabled = True # enabled without any provider credential
monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False)
text = next_steps.format_next_steps(cfg, config_path="/tmp/config.toml")
assert "Fix next:" in text
assert "onboard configure audio" not in text
assert "opensquilla onboard catalog audio" in text
def test_fix_next_audio_command_comes_from_the_headless_command_table(monkeypatch):
"""The audio fix line must advertise the command recorded once in
_HEADLESS_SETUP_COMMANDS — a rename there must not leave a stale
hardcoded copy in the Fix-next checklist."""
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding import next_steps
cfg = GatewayConfig()
cfg.audio.enabled = True # enabled without any provider credential
monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False)
monkeypatch.setitem(
next_steps._HEADLESS_SETUP_COMMANDS,
"audio",
("Audio recipes", "opensquilla onboard catalog audio-renamed"),
)
text = next_steps.format_next_steps(cfg, config_path="/tmp/config.toml")
fix_block = text.split("Fix next:", 1)[1]
assert "opensquilla onboard catalog audio-renamed --config /tmp/config.toml" in fix_block
assert "catalog audio --config" not in fix_block
def test_capability_summary_and_fix_lines_share_one_label_status_resolver(monkeypatch):
"""Both lines of the same printed block derive (label, display) through
_capability_section_view, so a wording change can never make the
Capabilities summary and the Fix-next checklist disagree."""
from opensquilla.gateway.config import GatewayConfig
from opensquilla.onboarding import next_steps
from opensquilla.onboarding.status import get_onboarding_status
cfg = GatewayConfig()
cfg.search_provider = "tavily"
cfg.search_api_key = ""
cfg.search_api_key_env = "DUMMY_UNSET_SEARCH_KEY"
monkeypatch.delenv("DUMMY_UNSET_SEARCH_KEY", raising=False)
status = get_onboarding_status(cfg)
label, display, _value, needs_action = next_steps._capability_section_view(
status, "search"
)
assert needs_action is True
summary = next_steps._capabilities_summary(status)
assert f"{label}={display}" in summary
fix_lines = next_steps._capability_fix_lines(status, "")
assert any(line.startswith(f" {label} ({display}): ") for line in fix_lines)