Files
quickbot_cli/tests/test_cli.py
Alexander Kalinovsky 1fa3cbbe1d
Some checks failed
CI / test (3.13) (push) Failing after 1m28s
chore: enhance CI workflow with caching, version display, and improved testing for CLI functionality
2025-08-27 19:51:28 +03:00

827 lines
33 KiB
Python

# SPDX-FileCopyrightText: 2025 Alexander Kalinovsky <a@k8y.ru>
#
# SPDX-License-Identifier: Apache-2.0
"""Tests for the CLI functionality."""
import inspect
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import typer
import yaml
from typer.testing import CliRunner
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from jinja2 import Environment, FileSystemLoader, StrictUndefined
from quickbot_cli.cli import (
_init_project,
app,
ask_variables,
init,
load_template_spec,
main,
render_tree,
run_post_tasks,
)
class TestLoadTemplateSpec:
"""Test template specification loading."""
def test_load_template_spec_with_valid_file(self, temp_dir: Path) -> None:
"""Test loading template spec from a valid YAML file."""
spec_file = temp_dir / "__template__.yaml"
spec_content = {
"variables": {"project_name": {"prompt": "Project name", "default": "test"}},
"post_tasks": [{"run": ["echo", "test"]}],
}
spec_file.write_text(yaml.dump(spec_content))
result = load_template_spec(temp_dir)
assert result == spec_content
def test_load_template_spec_without_file(self, temp_dir: Path) -> None:
"""Test loading template spec when file doesn't exist."""
result = load_template_spec(temp_dir)
assert result == {"variables": {}, "post_tasks": []}
def test_load_template_spec_with_invalid_yaml(self, temp_dir: Path) -> None:
"""Test loading template spec with invalid YAML."""
spec_file = temp_dir / "__template__.yaml"
spec_file.write_text("invalid: yaml: content: [")
with pytest.raises((typer.Exit, Exception)):
load_template_spec(temp_dir)
class TestAskVariables:
"""Test variable prompting and validation."""
def test_ask_variables_with_non_interactive(self) -> None:
"""Test asking variables with non-interactive mode."""
spec = {
"variables": {
"project_name": {"type": "string", "default": "my_project"},
"description": {"type": "string", "default": "A test project"},
}
}
non_interactive = {"project_name": "my_project", "description": "A test project"}
result = ask_variables(spec, non_interactive)
assert result["project_name"] == "my_project"
assert result["description"] == "A test project"
def test_ask_variables_with_choices_validation(self, mock_typer_prompt: MagicMock) -> None:
"""Test asking variables with choices validation."""
spec = {
"variables": {"include_alembic": {"prompt": "Include Alembic?", "choices": ["yes", "no"], "default": "yes"}}
}
non_interactive: dict[str, str] = {}
# Test valid choice
mock_typer_prompt.return_value = "yes"
result = ask_variables(spec, non_interactive)
assert result["include_alembic"] == "yes"
def test_ask_variables_with_invalid_choice(self, mock_typer_prompt: MagicMock) -> None:
"""Test asking variables with invalid choice."""
spec = {
"variables": {"include_alembic": {"prompt": "Include Alembic?", "choices": ["yes", "no"], "default": "yes"}}
}
non_interactive: dict[str, str] = {}
# Test invalid choice
mock_typer_prompt.return_value = "maybe"
with pytest.raises((SystemExit, Exception)):
ask_variables(spec, non_interactive)
def test_ask_variables_with_boolean_choices_true(self, mock_typer_prompt: MagicMock) -> None:
"""Boolean choices should coerce various truthy inputs to True."""
spec = {
"variables": {
"feature_flag": {
"prompt": "Enable feature?",
"choices": [True, False],
"default": True,
}
}
}
non_interactive: dict[str, str] = {}
# Try several truthy inputs
for truthy in [True, "true", "Yes", "Y", "1"]:
mock_typer_prompt.return_value = truthy
result = ask_variables(spec, non_interactive)
assert result["feature_flag"] is True
def test_ask_variables_with_boolean_choices_false(self, mock_typer_prompt: MagicMock) -> None:
"""Boolean choices should coerce various falsy inputs to False."""
spec = {
"variables": {
"feature_flag": {
"prompt": "Enable feature?",
"choices": [True, False],
"default": False,
}
}
}
non_interactive: dict[str, str] = {}
for falsy in [False, "false", "No", "n", "0"]:
mock_typer_prompt.return_value = falsy
result = ask_variables(spec, non_interactive)
assert result["feature_flag"] is False
def test_ask_variables_with_boolean_choices_invalid(self, mock_typer_prompt: MagicMock) -> None:
"""Invalid input for boolean choices should raise SystemExit."""
spec = {
"variables": {
"feature_flag": {
"prompt": "Enable feature?",
"choices": [True, False],
"default": True,
}
}
}
non_interactive: dict[str, str] = {}
mock_typer_prompt.return_value = "maybe"
with pytest.raises((SystemExit, Exception)):
ask_variables(spec, non_interactive)
def test_ask_variables_with_regex_validation(self, mock_typer_prompt: MagicMock) -> None:
"""Test asking variables with regex validation."""
spec = {
"variables": {
"project_name": {"prompt": "Project name", "default": "test", "validate": r"^[a-z_][a-z0-9_]*$"}
}
}
non_interactive: dict[str, str] = {}
# Test valid name
mock_typer_prompt.return_value = "valid_name"
result = ask_variables(spec, non_interactive)
assert result["project_name"] == "valid_name"
# Test invalid name
mock_typer_prompt.return_value = "Invalid-Name"
with pytest.raises((SystemExit, Exception)):
ask_variables(spec, non_interactive)
class TestRenderTree:
"""Test template file rendering."""
def test_render_tree_creates_directories(self, temp_dir: Path) -> None:
"""Test that render_tree creates directories correctly."""
template_root = temp_dir / "template"
template_root.mkdir()
(template_root / "app").mkdir()
(template_root / "app" / "models").mkdir()
output_dir = temp_dir / "output"
context = {"project_name": "test_project"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True)
render_tree(env, template_root, output_dir, context, overwrite=False)
assert (output_dir / "app" / "models").exists()
assert (output_dir / "app" / "models").is_dir()
def test_render_tree_renders_jinja2_files(self, temp_dir: Path) -> None:
"""Test that render_tree renders Jinja2 template files."""
template_root = temp_dir / "template"
template_root.mkdir()
template_file = template_root / "main.py.j2"
template_file.write_text("app = FastAPI(title='{{ project_name }}')")
output_dir = temp_dir / "output"
context = {"project_name": "test_project"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True)
render_tree(env, template_root, output_dir, context, overwrite=False)
output_file = output_dir / "main.py"
assert output_file.exists()
assert "app = FastAPI(title='test_project')" in output_file.read_text()
def test_render_tree_renders_regular_files(self, temp_dir: Path) -> None:
"""Test that render_tree renders regular text files."""
template_root = temp_dir / "template"
template_root.mkdir()
template_file = template_root / "README.md.j2"
template_file.write_text("# {{ project_name }}\n\n{{ description }}")
output_dir = temp_dir / "output"
context = {"project_name": "test_project", "description": "Test description"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True)
render_tree(env, template_root, output_dir, context, overwrite=False)
output_file = output_dir / "README.md"
assert output_file.exists()
assert "# test_project" in output_file.read_text()
assert "Test description" in output_file.read_text()
def test_render_tree_copies_binary_files(self, temp_dir: Path) -> None:
"""Test that render_tree copies binary files without modification."""
template_root = temp_dir / "template"
template_root.mkdir()
# Create a mock binary file
binary_file = template_root / "image.png"
binary_content = b"fake_png_data"
binary_file.write_bytes(binary_content)
output_dir = temp_dir / "output"
context = {"project_name": "test_project"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True)
render_tree(env, template_root, output_dir, context, overwrite=False)
output_file = output_dir / "image.png"
assert output_file.exists()
assert output_file.read_bytes() == binary_content
def test_render_tree_binary_file_existing_is_skipped(self, temp_dir: Path, mock_typer_secho: MagicMock) -> None:
"""Existing binary file should be left untouched when overwrite is disabled."""
template_root = temp_dir / "template"
template_root.mkdir()
# Create a mock binary file
binary_file = template_root / "image.png"
binary_content = b"fake_png_data"
binary_file.write_bytes(binary_content)
output_dir = temp_dir / "output"
output_dir.mkdir()
# Create existing binary file
existing_file = output_dir / "image.png"
existing_file.write_bytes(b"existing_binary_data")
context = {"project_name": "test_project"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True)
render_tree(env, template_root, output_dir, context, overwrite=False)
# Should remain the existing content
assert (output_dir / "image.png").read_bytes() == b"existing_binary_data"
# Should show warning
mock_typer_secho.assert_called_with(
f"Warning: Skipping existing file: {output_dir / 'image.png'}", fg=typer.colors.YELLOW
)
def test_render_tree_with_overwrite_disabled_skips_existing(
self, temp_dir: Path, mock_typer_secho: MagicMock
) -> None:
"""Existing text files should be skipped when overwrite is disabled."""
template_root = temp_dir / "template"
template_root.mkdir()
template_file = template_root / "main.py.j2"
template_file.write_text("app = FastAPI(title='{{ project_name }}')")
output_dir = temp_dir / "output"
output_dir.mkdir()
# Create existing file
existing_file = output_dir / "main.py"
existing_file.write_text("existing content")
context = {"project_name": "test_project"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True)
render_tree(env, template_root, output_dir, context, overwrite=False)
# File content should remain unchanged
assert (output_dir / "main.py").read_text() == "existing content"
# Should show warning
mock_typer_secho.assert_called_with(
f"Warning: Skipping existing file: {output_dir / 'main.py'}", fg=typer.colors.YELLOW
)
def test_render_tree_with_overwrite_enabled(self, temp_dir: Path) -> None:
"""Test that render_tree overwrites existing files when enabled."""
template_root = temp_dir / "template"
template_root.mkdir()
template_file = template_root / "main.py.j2"
template_file.write_text("app = FastAPI(title='{{ project_name }}')")
output_dir = temp_dir / "output"
output_dir.mkdir()
# Create existing file
existing_file = output_dir / "main.py"
existing_file.write_text("existing content")
context = {"project_name": "test_project"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True)
render_tree(env, template_root, output_dir, context, overwrite=True)
output_file = output_dir / "main.py"
assert output_file.exists()
assert "app = FastAPI(title='test_project')" in output_file.read_text()
class TestRunPostTasks:
"""Test post-task execution."""
def test_run_post_tasks_with_conditions(self, temp_dir: Path) -> None:
"""Test running post tasks with conditional execution."""
spec = {
"post_tasks": [
{"when": "{{ include_alembic }}", "run": ["echo", "alembic_init"]},
{"when": "{{ include_i18n }}", "run": ["echo", "babel_init"]},
]
}
context = {"include_alembic": True, "include_i18n": False}
cwd = temp_dir / "test_cwd"
cwd.mkdir(parents=True, exist_ok=True)
run_post_tasks(spec, context, cwd)
def test_run_post_tasks_without_conditions(self, temp_dir: Path) -> None:
"""Test running post tasks without conditions."""
spec = {"post_tasks": [{"run": ["echo", "hello"]}, {"run": ["echo", "world"]}, {"run": ["echo", "test"]}]}
context: dict[str, str] = {}
cwd = temp_dir / "test_cwd"
cwd.mkdir(parents=True, exist_ok=True)
run_post_tasks(spec, context, cwd)
def test_run_post_tasks_with_subprocess_error_continues(self, temp_dir: Path) -> None:
"""Test that post task errors don't stop execution."""
# This test verifies that subprocess errors don't stop execution
# The actual error handling is tested in the main run_post_tasks function
class TestPostTasksApplyOptionalsBehavior:
"""Optional module inclusion/exclusion is applied via post_tasks now."""
def test_post_tasks_removes_alembic_when_disabled(self, temp_dir: Path) -> None:
"""post_tasks should remove Alembic artifacts when disabled."""
# Create structure
(temp_dir / "alembic").mkdir()
scripts_dir = temp_dir / "scripts"
scripts_dir.mkdir()
(scripts_dir / "migrations_generate.sh").write_text("script")
(scripts_dir / "migrations_apply.sh").write_text("script")
spec = {
"post_tasks": [
{
"when": "{{ not include_alembic }}",
"run": [
"rm",
"-rf",
"alembic",
"scripts/migrations_apply.sh",
"scripts/migrations_generate.sh",
],
}
]
}
context = {"include_alembic": False}
run_post_tasks(spec, context, temp_dir)
assert not (temp_dir / "alembic").exists()
assert not (scripts_dir / "migrations_generate.sh").exists()
assert not (scripts_dir / "migrations_apply.sh").exists()
def test_post_tasks_removes_i18n_when_disabled(self, temp_dir: Path) -> None:
"""post_tasks should remove i18n artifacts when disabled."""
(temp_dir / "locales").mkdir()
scripts_dir = temp_dir / "scripts"
scripts_dir.mkdir()
for f in [
"babel_init.sh",
"babel_extract.sh",
"babel_update.sh",
"babel_compile.sh",
]:
(scripts_dir / f).write_text("script")
spec = {
"post_tasks": [
{
"when": "{{ not include_i18n }}",
"run": [
"rm",
"-rf",
"locales",
"scripts/babel_compile.sh",
"scripts/babel_extract.sh",
"scripts/babel_init.sh",
"scripts/babel_update.sh",
],
}
]
}
context = {"include_i18n": False}
run_post_tasks(spec, context, temp_dir)
assert not (temp_dir / "locales").exists()
assert not (scripts_dir / "babel_init.sh").exists()
assert not (scripts_dir / "babel_extract.sh").exists()
assert not (scripts_dir / "babel_update.sh").exists()
assert not (scripts_dir / "babel_compile.sh").exists()
class TestInitCommand:
"""Test the main init command."""
def test_init_command_success(
self,
temp_dir: Path,
) -> None:
"""Test successful project initialization."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
# Create template structure
template_dir = temp_dir / "templates" / "basic"
template_dir.mkdir(parents=True)
# Create template spec
spec_file = template_dir / "__template__.yaml"
spec_file.write_text("variables:\n project_name:\n prompt: Project name\n default: test_project")
# Create template files
(template_dir / "app").mkdir()
(template_dir / "app" / "main.py.j2").write_text("app = FastAPI(title='{{ project_name }}')")
# Test the init function directly instead of through CLI
output_path = temp_dir / "output"
_init_project(output_path, "basic")
def test_init_command_with_template_not_found(self, temp_dir: Path) -> None:
"""Test init command when template is not found."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
output_path = temp_dir / "output"
with pytest.raises(FileNotFoundError, match="Template 'nonexistent' not found"):
_init_project(output_path, "nonexistent")
def test_init_command_with_template_not_found_error_message(self, temp_dir: Path) -> None:
"""Test that template not found shows the correct error message."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
output_path = temp_dir / "output"
with pytest.raises(FileNotFoundError, match="Template 'nonexistent' not found"):
_init_project(output_path, "nonexistent")
# The function now raises FileNotFoundError directly, so no typer.secho call
# This test verifies the exception is raised with the correct message
def test_init_command_with_non_interactive_options(
self,
temp_dir: Path,
) -> None:
"""Test init command with non-interactive options."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
# Create template structure
template_dir = temp_dir / "templates" / "basic"
template_dir.mkdir(parents=True)
# Create template spec
spec_file = template_dir / "__template__.yaml"
spec_file.write_text("variables:\n project_name:\n prompt: Project name\n default: test_project")
# Create template files
(template_dir / "app").mkdir()
(template_dir / "app" / "main.py.j2").write_text("app = FastAPI(title='{{ project_name }}')")
# Test the init function directly instead of through CLI
output_path = temp_dir / "output"
_init_project(
output_path,
"basic",
project_name="my_project",
description="A test project",
author="Test Author",
license_name="MIT",
include_alembic=True,
include_i18n=False,
overwrite=False,
)
def test_init_command_with_overwrite(
self,
temp_dir: Path,
) -> None:
"""Test init command with overwrite flag."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
# Create template structure
template_dir = temp_dir / "templates" / "basic"
template_dir.mkdir(parents=True)
# Create template spec
spec_file = template_dir / "__template__.yaml"
spec_file.write_text("variables:\n project_name:\n prompt: Project name\n default: test_project")
# Create template files
(template_dir / "app").mkdir()
(template_dir / "app" / "main.py.j2").write_text("app = FastAPI(title='{{ project_name }}')")
# Test the init function directly instead of through CLI
# Call init function directly with overwrite
output_path = temp_dir / "output"
_init_project(output_path, "basic", overwrite=True)
def test_init_into_existing_dir_with_pyproject_is_ok_when_not_overwriting(self, temp_dir: Path) -> None:
"""Regression: initializing into dir with existing pyproject.toml should skip it and proceed."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
template_dir = temp_dir / "templates" / "basic"
template_dir.mkdir(parents=True)
# Spec and minimal files including pyproject template
(template_dir / "__template__.yaml").write_text(
"variables:\n project_name:\n prompt: Project name\n default: test_project"
)
(template_dir / "app").mkdir()
(template_dir / "app" / "main.py.j2").write_text("ok")
(template_dir / "pyproject.toml.j2").write_text("[project]\nname='{{ project_name }}'")
# Prepare existing output with pyproject.toml
output_path = temp_dir / "output"
output_path.mkdir()
(output_path / "pyproject.toml").write_text("[project]\nname='existing'")
# Should not raise, and should keep existing pyproject.toml
_init_project(output_path, "basic", overwrite=False)
assert (output_path / "pyproject.toml").read_text() == "[project]\nname='existing'"
# New files should be generated
assert (output_path / "app" / "main.py").exists()
def test_cli_boolean_flags_defaults_and_negation(self, temp_dir: Path) -> None:
"""init() should honor boolean defaults and negation when called directly."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
template_dir = temp_dir / "templates" / "basic"
template_dir.mkdir(parents=True)
# Spec with variables and post_tasks to remove disabled modules
(template_dir / "__template__.yaml").write_text(
"""
variables:
project_name:
prompt: P
default: test_project
include_alembic:
prompt: A
choices: [true, false]
default: true
include_i18n:
prompt: I
choices: [true, false]
default: true
post_tasks:
- when: "{{ not include_alembic }}"
run: [
"rm","-rf",
"alembic",
"scripts/migrations_apply.sh",
"scripts/migrations_generate.sh"
]
- when: "{{ not include_i18n }}"
run: [
"rm","-rf",
"locales",
"scripts/babel_compile.sh",
"scripts/babel_extract.sh",
"scripts/babel_init.sh",
"scripts/babel_update.sh"
]
"""
)
(template_dir / "app").mkdir()
(template_dir / "app" / "main.py.j2").write_text("ok")
(template_dir / "alembic").mkdir()
(template_dir / "alembic" / "alembic.ini.j2").write_text("a")
(template_dir / "locales").mkdir()
(template_dir / "locales" / "en").mkdir(parents=True, exist_ok=True)
(template_dir / "scripts").mkdir()
(template_dir / "scripts" / "babel_init.sh.j2").write_text("b")
(template_dir / "scripts" / "babel_extract.sh.j2").write_text("b")
(template_dir / "scripts" / "babel_update.sh.j2").write_text("b")
(template_dir / "scripts" / "babel_compile.sh.j2").write_text("b")
(template_dir / "scripts" / "migrations_apply.sh.j2").write_text("b")
(template_dir / "scripts" / "migrations_generate.sh.j2").write_text("b")
# Default (both enabled)
out1 = temp_dir / "out1"
init(output=out1, template="basic")
assert (out1 / "alembic").exists()
assert (out1 / "locales").exists()
# Disable alembic
out2 = temp_dir / "out2"
init(output=out2, template="basic", include_alembic=False)
assert not (out2 / "alembic").exists()
assert (out2 / "locales").exists()
# Disable i18n
out3 = temp_dir / "out3"
init(output=out3, template="basic", include_i18n=False)
assert (out3 / "alembic").exists()
assert not (out3 / "locales").exists()
def test_init_command_project_name_fallback(self, temp_dir: Path) -> None:
"""Test that project_name falls back to output directory name when not provided."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
# Create template structure
template_dir = temp_dir / "templates" / "basic"
template_dir.mkdir(parents=True)
# Create template spec WITHOUT project_name variable
spec_file = template_dir / "__template__.yaml"
spec_file.write_text("variables:\n description:\n prompt: Description\n default: A test project")
# Create template files
(template_dir / "app").mkdir()
(template_dir / "app" / "main.py.j2").write_text("app = FastAPI(title='{{ project_name }}')")
# Test with output directory that has a name (to test the fallback)
output_path = temp_dir / "my_project_output"
_init_project(
output=output_path,
template="basic",
project_name=None, # Explicitly set to None to trigger fallback
description="A test project",
author="Test Author",
license_name="MIT",
include_alembic=False,
include_i18n=False,
overwrite=False,
interactive=False, # Disable interactive mode to avoid prompting
)
# Verify that project_name was set to the output directory name
# This tests the fallback logic in line 297
assert output_path.exists()
# The project_name should be "my_project_output" (the directory name)
class TestCLIHelp:
"""Test CLI help and argument parsing."""
def test_cli_help(self, cli_runner: CliRunner) -> None:
"""Test that CLI shows help information."""
# Test the actual CLI interface
result = cli_runner.invoke(app, ["--help"], env={"NO_COLOR": "1", "COLUMNS": "120"})
assert result.exit_code == 0
# Check for the actual help text that appears
assert "init" in result.output
assert "version" in result.output
def test_init_command_help(self, cli_runner: CliRunner) -> None:
"""Test that init command shows help information."""
# Test the actual CLI interface with more robust environment settings
result = cli_runner.invoke(
app,
["init", "--help"],
env={"NO_COLOR": "1", "COLUMNS": "120", "TERM": "dumb", "PYTHONIOENCODING": "utf-8"},
)
assert result.exit_code == 0
# Check for the actual help text that appears
# In CI, the output might be truncated, so we check for key elements
output = result.output.lower()
assert "output" in output or "--output" in output
# Check for other key help elements that should be present
assert any(keyword in output for keyword in ["directory", "project", "description", "author"])
def test_init_command_arguments(self, cli_runner: CliRunner) -> None:
"""Test that init command accepts required arguments."""
# Test the actual CLI interface with more robust environment settings
result = cli_runner.invoke(
app,
["init", "--help"],
env={"NO_COLOR": "1", "COLUMNS": "120", "TERM": "dumb", "PYTHONIOENCODING": "utf-8"},
)
assert result.exit_code == 0
# In CI, the output might be truncated, so we check for key elements
output = result.output.lower()
assert "output" in output or "--output" in output
def test_cli_wrapper_function(self) -> None:
"""Test that the CLI wrapper function exists and is callable."""
# Verify the function exists and is callable
assert callable(init)
# Check that it has the expected signature
sig = inspect.signature(init)
assert "output" in sig.parameters
assert "template" in sig.parameters
def test_main_function(self) -> None:
"""Test that the main function exists and is callable."""
assert callable(main)
def test_cli_command_execution(self) -> None:
"""Test that the CLI wrapper function has the correct signature and behavior."""
# Test that the function exists and has the right signature
assert callable(init)
# Check the function signature
sig = inspect.signature(init)
# Verify all expected parameters are present
expected_params = [
"output",
"template",
"project_name",
"description",
"author",
"license_name",
"include_alembic",
"include_i18n",
"overwrite",
]
for param in expected_params:
assert param in sig.parameters
# Test that the function is properly decorated as a Typer command
# We can't easily test the full execution due to Typer decorators,
# but we can verify the function structure
assert hasattr(init, "__name__")
assert init.__name__ == "init"
def test_version_command(self, cli_runner: CliRunner) -> None:
"""Test that version command works and shows version information."""
# Test the actual CLI interface
result = cli_runner.invoke(app, ["version"], env={"NO_COLOR": "1", "COLUMNS": "120"})
assert result.exit_code == 0
# Check that it shows version information
assert "quickbot-cli version" in result.output
def test_init_command_basic_functionality(self, cli_runner: CliRunner, tmp_path: Path) -> None:
"""Test that init command actually works with basic functionality."""
# Create a test output directory
output_dir = tmp_path / "test_project"
# Test basic init command execution
result = cli_runner.invoke(
app,
[
"init",
"--output",
str(output_dir),
"--project-name",
"Test Project",
"--description",
"Test Description",
"--author",
"Test Author",
"--license-name",
"MIT",
"--no-include-alembic",
"--no-include-i18n",
"--no-interactive",
],
env={"NO_COLOR": "1", "COLUMNS": "120"},
)
# Should succeed
assert result.exit_code == 0
assert "Project generated successfully" in result.output
# Check that files were actually created
assert output_dir.exists()
assert (output_dir / "pyproject.toml").exists()
assert (output_dir / "README.md").exists()
assert (output_dir / "app").exists()
class TestCLIOverwriteParsing:
"""Test overwrite string parsing through the init function (covers conversion)."""
def test_overwrite_true_converted_to_bool(self, tmp_path: Path) -> None:
"""Test that overwrite True is passed to _init_project."""
output_dir = tmp_path / "output"
with patch("quickbot_cli.cli._init_project") as mock_init:
# Call the function directly to exercise conversion logic
init(
output=output_dir,
template="basic",
overwrite=True,
)
mock_init.assert_called_once()
kwargs = mock_init.call_args.kwargs
assert kwargs["overwrite"] is True
def test_overwrite_false_converted_to_bool(self, tmp_path: Path) -> None:
"""Test that overwrite False is passed to _init_project."""
output_dir = tmp_path / "output"
with patch("quickbot_cli.cli._init_project") as mock_init:
init(
output=output_dir,
template="basic",
overwrite=False,
)
kwargs = mock_init.call_args.kwargs
assert kwargs["overwrite"] is False