diff --git a/music_cli/cli.py b/music_cli/cli.py index 9136037..11d4b22 100644 --- a/music_cli/cli.py +++ b/music_cli/cli.py @@ -150,7 +150,38 @@ def start_daemon_background() -> None: ) -@click.group(invoke_without_command=True) +class AliasedGroup(click.Group): + """Click group that supports hidden command aliases. + + Aliases are registered as a mapping from alias name to real command name. + They are hidden from --help output but fully functional. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._aliases: dict[str, str] = {} + + def add_alias(self, alias: str, target: str) -> None: + """Register *alias* as a hidden forwarding name for *target*.""" + self._aliases[alias] = target + + def get_command(self, ctx, cmd_name): + """Resolve aliases before normal lookup.""" + # Check if the cmd_name is an alias + real_name = self._aliases.get(cmd_name, cmd_name) + return super().get_command(ctx, real_name) + + def format_usage(self, ctx, formatter): + """Standard usage — aliases stay hidden.""" + super().format_usage(ctx, formatter) + + +def _register_alias(group: AliasedGroup, alias: str, target: str) -> None: + """Register a hidden alias on an AliasedGroup.""" + group.add_alias(alias, target) + + +@click.group(cls=AliasedGroup, invoke_without_command=True) @click.version_option(__version__) @click.pass_context def main(ctx): @@ -162,6 +193,9 @@ def main(ctx): # Check for updates on any command if ctx.invoked_subcommand is not None: _check_for_updates_once() + elif ctx.invoked_subcommand is None: + # Bare invocation: show status + ctx.invoke(status) @main.command() @@ -175,6 +209,7 @@ def main(ctx): @click.option("--source", "-s", help="Source file/URL/station name") @click.option( "--mood", + "-M", type=click.Choice( ["happy", "sad", "excited", "focus", "relaxed", "energetic", "melancholic", "peaceful"] ), @@ -192,6 +227,7 @@ def play(mode, source, mood, auto, duration, index): music-cli play -m local -s song.mp3 # Play local file music-cli play -m radio -s "chill" # Play radio station by name music-cli play --mood focus # Play focus music + music-cli play -M focus # Play focus music (short flag) music-cli play -m ai --mood happy # Generate happy AI music music-cli play -m history -i 3 # Replay 3rd item from history music-cli play -m local --auto # Shuffle local library @@ -366,15 +402,16 @@ def next_track(): sys.exit(1) -@main.command() +@main.command("vol") @click.argument("level", type=int, required=False) def volume(level): """Get or set volume (0-100). \b Examples: - music-cli volume # Show current volume - music-cli volume 50 # Set volume to 50% + mc vol # Show current volume + mc vol 50 # Set volume to 50% + music-cli volume # Old name still works """ client = ensure_daemon() @@ -390,7 +427,7 @@ def volume(level): sys.exit(1) -@main.group("radios", invoke_without_command=True) +@main.group("radio", cls=AliasedGroup, invoke_without_command=True) @click.pass_context def radios_group(ctx): """Manage radio stations. @@ -404,11 +441,11 @@ def radios_group(ctx): \b Examples: - music-cli radios # List all stations - music-cli radios list # List all stations - music-cli radios play 5 # Play station #5 - music-cli radios add # Add new station interactively - music-cli radios remove 3 # Remove station #3 + mc radio # List all stations + mc radio list # List all stations + mc radio play 5 # Play station #5 + mc radio add # Add new station interactively + mc radio remove 3 # Remove station #3 """ if ctx.invoked_subcommand is None: # Default action: list radios @@ -679,7 +716,7 @@ def show_config(): click.echo(f" PID: {config.pid_file}") -@main.command("moods") +@main.command("mood") def list_moods(): """List available mood tags.""" from .context.mood import MoodContext @@ -687,10 +724,10 @@ def list_moods(): click.echo("Available moods:") for mood in MoodContext.get_all_moods(): click.echo(f" - {mood}") - click.echo("\nUse with: music-cli play --mood ") + click.echo("\nUse with: mc play --mood ") -@main.group("ai", invoke_without_command=True) +@main.group("ai", cls=AliasedGroup, invoke_without_command=True) @click.pass_context def ai_group(ctx): """Manage AI-generated music tracks. @@ -754,6 +791,7 @@ def ai_list(): @click.option("-p", "--prompt", help="Custom prompt for AI music generation") @click.option( "--mood", + "-M", type=click.Choice( ["happy", "sad", "excited", "focus", "relaxed", "energetic", "melancholic", "peaceful"] ), @@ -1143,31 +1181,31 @@ def ai_remove(index): sys.exit(1) -@main.group("youtube", invoke_without_command=True) +@main.group("yt", cls=AliasedGroup, invoke_without_command=True) @click.pass_context def youtube_group(ctx): """Manage cached YouTube audio for offline playback. - \\b + \b Commands: - cached - Show all cached YouTube tracks (default) + list - Show all cached YouTube tracks (default) play - Play a cached track by number (offline) remove - Remove a cached track clear - Clear all cached tracks - \\b + \b Examples: - music-cli youtube # List cached tracks - music-cli youtube cached # List cached tracks - music-cli youtube play 3 # Play cached track #3 - music-cli youtube remove 1 # Remove track #1 - music-cli youtube clear # Clear entire cache + mc yt # List cached tracks + mc yt list # List cached tracks + mc yt play 3 # Play cached track #3 + mc yt remove 1 # Remove track #1 + mc yt clear # Clear entire cache """ if ctx.invoked_subcommand is None: ctx.invoke(youtube_cached) -@youtube_group.command("cached") +@youtube_group.command("list") def youtube_cached(): client = ensure_daemon() @@ -1352,5 +1390,27 @@ def update_radios(): click.echo(f"Config version updated to {__version__}") +# --------------------------------------------------------------------------- +# Register aliases (hidden — don't appear in --help but work on the CLI) +# --------------------------------------------------------------------------- + +# Task 1.3: Old group/command names → hidden aliases on main +_register_alias(main, "radios", "radio") +_register_alias(main, "youtube", "yt") +_register_alias(main, "moods", "mood") +_register_alias(main, "volume", "vol") + +# Task 1.4: Playback single-letter/short aliases +_register_alias(main, "s", "stop") +_register_alias(main, "pp", "pause") +_register_alias(main, "r", "resume") +_register_alias(main, "n", "next") +_register_alias(main, "st", "status") +_register_alias(main, "h", "history") + +# Task 1.3: Inside yt group, "cached" → "list" +_register_alias(youtube_group, "cached", "list") + + if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml index cc69340..56886df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ dev = [ [project.scripts] music-cli = "music_cli.cli:main" +mc = "music_cli.cli:main" [project.urls] Homepage = "https://github.com/luongnv89/music-cli" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..6f9450f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,242 @@ +"""Tests for CLI v2 Phase 1: Foundation — mc alias, short names, playback aliases.""" + +import pytest +from click.testing import CliRunner + +from music_cli.cli import main + + +@pytest.fixture +def runner(): + """Create a Click CliRunner.""" + return CliRunner() + + +# ------------------------------------------------------------------------- +# 1.1 — mc entry point (both entry points share the same `main` group) +# ------------------------------------------------------------------------- + + +class TestHelpOutput: + """Verify --help contains expected commands with new names.""" + + def test_help_shows_new_group_names(self, runner): + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + # New primary names must appear + for cmd in ("radio", "yt", "mood", "vol"): + assert cmd in result.output + + def test_help_hides_old_group_names(self, runner): + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + # Old names must NOT appear in the help listing + # They should still work as hidden aliases + for old in ("radios", "youtube", "moods", "volume"): + # Make sure the old name doesn't appear as a listed command. + # We check it doesn't appear on a line starting with whitespace (command listing). + lines = result.output.splitlines() + command_lines = [l.strip().split()[0] for l in lines if l.startswith(" ") and l.strip()] + assert old not in command_lines, f"Old name '{old}' should be hidden from --help" + + def test_help_hides_playback_aliases(self, runner): + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + lines = result.output.splitlines() + command_lines = [l.strip().split()[0] for l in lines if l.startswith(" ") and l.strip()] + for alias in ("s", "pp", "r", "n", "st", "h"): + assert alias not in command_lines, f"Alias '{alias}' should be hidden from --help" + + def test_help_shows_expected_commands(self, runner): + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + for cmd in ("play", "stop", "pause", "resume", "next", "status", "history", + "radio", "yt", "mood", "vol", "ai", "daemon", "config"): + assert cmd in result.output + + def test_version(self, runner): + result = runner.invoke(main, ["--version"]) + assert result.exit_code == 0 + assert "version" in result.output.lower() + + +# ------------------------------------------------------------------------- +# 1.2 + 1.3 — Alias infrastructure & renamed groups +# ------------------------------------------------------------------------- + + +class TestGroupRenames: + """Old group names resolve to the same commands as new names.""" + + def test_radio_help(self, runner): + result = runner.invoke(main, ["radio", "--help"]) + assert result.exit_code == 0 + assert "radio" in result.output.lower() + + def test_radios_alias_resolves(self, runner): + result = runner.invoke(main, ["radios", "--help"]) + assert result.exit_code == 0 + assert "radio" in result.output.lower() + + def test_yt_help(self, runner): + result = runner.invoke(main, ["yt", "--help"]) + assert result.exit_code == 0 + assert "youtube" in result.output.lower() or "yt" in result.output.lower() + + def test_youtube_alias_resolves(self, runner): + result = runner.invoke(main, ["youtube", "--help"]) + assert result.exit_code == 0 + + def test_mood_help(self, runner): + result = runner.invoke(main, ["mood", "--help"]) + assert result.exit_code == 0 + + def test_moods_alias_resolves(self, runner): + result = runner.invoke(main, ["moods", "--help"]) + assert result.exit_code == 0 + + def test_vol_help(self, runner): + result = runner.invoke(main, ["vol", "--help"]) + assert result.exit_code == 0 + assert "volume" in result.output.lower() + + def test_volume_alias_resolves(self, runner): + result = runner.invoke(main, ["volume", "--help"]) + assert result.exit_code == 0 + assert "volume" in result.output.lower() + + def test_yt_cached_alias(self, runner): + """'yt cached' should resolve to 'yt list' (hidden alias).""" + result = runner.invoke(main, ["yt", "cached", "--help"]) + assert result.exit_code == 0 + + def test_yt_list_help(self, runner): + result = runner.invoke(main, ["yt", "list", "--help"]) + assert result.exit_code == 0 + + +# ------------------------------------------------------------------------- +# 1.4 — Playback command aliases +# ------------------------------------------------------------------------- + + +class TestPlaybackAliases: + """Single-letter and short aliases resolve to real commands.""" + + @pytest.mark.parametrize( + "alias,target_cmd", + [ + ("s", "stop"), + ("pp", "pause"), + ("r", "resume"), + ("n", "next"), + ("st", "status"), + ("h", "history"), + ], + ) + def test_alias_resolves_to_help(self, runner, alias, target_cmd): + """Each alias should resolve without error when given --help.""" + result = runner.invoke(main, [alias, "--help"]) + assert result.exit_code == 0, f"Alias '{alias}' failed: {result.output}" + + @pytest.mark.parametrize( + "alias,target_cmd", + [ + ("s", "stop"), + ("pp", "pause"), + ("r", "resume"), + ("n", "next"), + ("st", "status"), + ("h", "history"), + ], + ) + def test_alias_help_matches_target(self, runner, alias, target_cmd): + """Alias help output should match the target command help output.""" + alias_result = runner.invoke(main, [alias, "--help"]) + target_result = runner.invoke(main, [target_cmd, "--help"]) + # The help text body should be the same (ignoring the usage line which + # shows the invoked name) + alias_body = "\n".join(alias_result.output.splitlines()[1:]) + target_body = "\n".join(target_result.output.splitlines()[1:]) + assert alias_body == target_body + + +# ------------------------------------------------------------------------- +# 1.5 — Bare invocation → status +# ------------------------------------------------------------------------- + + +class TestBareInvocation: + """Bare `mc` (no subcommand) should invoke status.""" + + def test_bare_invocation_does_not_crash(self, runner): + """Bare invocation should not crash (exit code 0 or 1 for daemon not running).""" + result = runner.invoke(main, []) + # We accept exit_code 1 because the daemon likely isn't running in test, + # but it must not be exit_code 2 (Click usage error). + assert result.exit_code in (0, 1), ( + f"Bare invocation crashed with exit code {result.exit_code}: {result.output}" + ) + + def test_bare_invocation_attempts_status(self, runner): + """Bare invocation should try to show status (connect to daemon).""" + result = runner.invoke(main, []) + # Either shows status output or a connection error — both prove status was invoked + output_lower = result.output.lower() + assert ( + "status" in output_lower + or "error" in output_lower + or "daemon" in output_lower + or "starting" in output_lower + ), f"Bare invocation did not attempt status: {result.output}" + + +# ------------------------------------------------------------------------- +# 1.6 — -M short flag for --mood +# ------------------------------------------------------------------------- + + +class TestMoodShortFlag: + """The -M flag works on both play and ai play.""" + + def test_play_has_mood_short_flag(self, runner): + result = runner.invoke(main, ["play", "--help"]) + assert result.exit_code == 0 + assert "-M" in result.output + + def test_ai_play_has_mood_short_flag(self, runner): + result = runner.invoke(main, ["ai", "play", "--help"]) + assert result.exit_code == 0 + assert "-M" in result.output + + +# ------------------------------------------------------------------------- +# Integration: all subcommand --help should resolve cleanly +# ------------------------------------------------------------------------- + + +class TestSubcommandHelp: + """Every primary command/group shows --help without errors.""" + + @pytest.mark.parametrize( + "cmd_args", + [ + ["play", "--help"], + ["stop", "--help"], + ["pause", "--help"], + ["resume", "--help"], + ["next", "--help"], + ["status", "--help"], + ["history", "--help"], + ["radio", "--help"], + ["yt", "--help"], + ["mood", "--help"], + ["vol", "--help"], + ["ai", "--help"], + ["daemon", "--help"], + ["config", "--help"], + ], + ) + def test_command_help_succeeds(self, runner, cmd_args): + result = runner.invoke(main, cmd_args) + assert result.exit_code == 0, f"Failed for {cmd_args}: {result.output}"