# tests/test_cli.py import json from pathlib import Path from voice_gen.cli import main def test_init_creates_skeleton(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) rc = main(["init"]) assert rc == 0 assert (tmp_path / "voices.json").exists() assert (tmp_path / "scripts").is_dir() assert (tmp_path / "output").is_dir() # voices.json is empty registry import json data = json.loads((tmp_path / "voices.json").read_text(encoding="utf-8")) assert data == {"voices": {}} def test_init_is_idempotent(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) assert main(["init"]) == 0 (tmp_path / "voices.json").write_text('{"voices": {"x": {}}}', encoding="utf-8") assert main(["init"]) == 0 # existing file should NOT be overwritten assert (tmp_path / "voices.json").read_text(encoding="utf-8") == '{"voices": {"x": {}}}' def test_no_args_prints_help_and_returns_2(capsys): rc = main([]) assert rc == 2 # argparse default for missing required captured = capsys.readouterr() assert "usage:" in captured.err.lower() def test_voices_add_uploads_and_registers(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) # bootstrap registry assert main(["init"]) == 0 # fake audio file audio = tmp_path / "anchor.mp3" audio.write_bytes(b"fake") # monkeypatch upload + clone (both hit external API) from voice_gen import cli calls = {"upload": None, "clone": None} def fake_upload(path, purpose="voice_clone"): calls["upload"] = {"path": path, "purpose": purpose} return {"file_id": 555, "filename": path.name} def fake_clone(file_id, voice_id): calls["clone"] = {"file_id": file_id, "voice_id": voice_id} monkeypatch.setattr(cli, "_upload_voice_file", fake_upload) monkeypatch.setattr(cli, "_clone_voice_file", fake_clone) rc = main(["voices", "add", str(audio)]) assert rc == 0 assert calls["upload"]["path"] == audio assert calls["upload"]["purpose"] == "voice_clone" # friendly name derived from filename stem (audio.stem == "anchor") assert calls["clone"] == {"file_id": 555, "voice_id": "anchor"} data = json.loads((tmp_path / "voices.json").read_text(encoding="utf-8")) assert "anchor" in data["voices"] assert data["voices"]["anchor"]["file_id"] == 555 assert data["voices"]["anchor"]["filename"] == "anchor.mp3" # notes field removed; only file_id, filename, created_at remain assert set(data["voices"]["anchor"].keys()) == {"file_id", "filename", "created_at"} def test_voices_add_rejects_missing_audio(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) assert main(["init"]) == 0 rc = main(["voices", "add", str(tmp_path / "nope.mp3")]) assert rc == 2 # argparse usage error def test_voices_cleanup_dedupes_by_filename(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) assert main(["init"]) == 0 from voice_gen import cli deleted_ids = [] def fake_delete(file_id): deleted_ids.append(file_id) return True monkeypatch.setattr(cli, "_delete_remote_file", fake_delete) import json reg_path = tmp_path / "voices.json" reg_path.write_text(json.dumps({ "voices": { "dup1": {"file_id": 1, "filename": "same.mp3", "created_at": "2026-06-01T00:00:00Z"}, "dup2": {"file_id": 2, "filename": "same.mp3", "created_at": "2026-06-02T00:00:00Z"}, "uniq": {"file_id": 3, "filename": "other.mp3", "created_at": "2026-06-03T00:00:00Z"}, } }), encoding="utf-8") rc = main(["voices", "cleanup"]) assert rc == 0 # The earlier-created one (lower file_id 1) should be kept; 2 deleted assert 2 in deleted_ids assert 1 not in deleted_ids data = json.loads(reg_path.read_text(encoding="utf-8")) assert "dup1" in data["voices"] assert "dup2" not in data["voices"] assert "uniq" in data["voices"] def test_gen_batch_end_to_end(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) assert main(["init"]) == 0 # scripts/ scripts = tmp_path / "scripts" scripts.mkdir(exist_ok=True) (scripts / "intro.md").write_text("# Title\n\nFirst narration.", encoding="utf-8") (scripts / "outro.md").write_text("Closing words.", encoding="utf-8") # seed registry import json from datetime import datetime, timezone (tmp_path / "voices.json").write_text(json.dumps({ "voices": { "anchor": { "file_id": 42, "filename": "a.mp3", "created_at": datetime.now(timezone.utc).isoformat(), } } }), encoding="utf-8") # fake mmx run from voice_gen import synthesizer captured = [] def fake_run(cmd, **kwargs): captured.append(cmd) out = Path(cmd[cmd.index("--out") + 1]) out.parent.mkdir(parents=True, exist_ok=True) out.write_bytes(b"audio") return type("R", (), {"returncode": 0, "stderr": ""})() monkeypatch.setattr(synthesizer, "_default_run", fake_run) rc = main(["gen", "--voice", "anchor", "scripts"]) assert rc == 0 out_dir = tmp_path / "output" assert (out_dir / "intro.mp3").exists() assert (out_dir / "outro.mp3").exists() assert len(captured) == 2 # voice_id passed to mmx is the friendly name (dict key), NOT the file_id. # The file_id in the registry entry is just for tracking the source audio; # after voice_clone, the API uses the friendly name as the voice_id. for cmd in captured: i = cmd.index("--voice") assert cmd[i + 1] == "anchor" def test_gen_status_output_is_ascii_safe(tmp_path, monkeypatch): import io import sys monkeypatch.chdir(tmp_path) assert main(["init"]) == 0 (tmp_path / "scripts" / "中文口播.md").write_text("测试。", encoding="utf-8") (tmp_path / "voices.json").write_text(json.dumps({ "voices": { "anchor": { "file_id": 42, "filename": "a.mp3", "created_at": "2026-07-10T00:00:00Z", } } }), encoding="utf-8") from voice_gen import synthesizer def fake_run(cmd, **kwargs): out = Path(cmd[cmd.index("--out") + 1]) out.write_bytes(b"audio") return type("R", (), {"returncode": 0, "stderr": ""})() monkeypatch.setattr(synthesizer, "_default_run", fake_run) buffer = io.BytesIO() ascii_stdout = io.TextIOWrapper(buffer, encoding="ascii", errors="strict") monkeypatch.setattr(sys, "stdout", ascii_stdout) assert main(["gen", "--voice", "anchor", "scripts"]) == 0 ascii_stdout.flush() output = buffer.getvalue().decode("ascii") assert "\\u4e2d\\u6587\\u53e3\\u64ad.md" in output def test_voices_add_rejects_duplicate_local_name(tmp_path, monkeypatch): """Re-adding a file whose stem matches an existing local entry errors out.""" monkeypatch.chdir(tmp_path) assert main(["init"]) == 0 from voice_gen import cli def fake_upload(path, purpose="voice_clone"): return {"file_id": 999, "filename": path.name} def fake_clone(file_id, voice_id): pass monkeypatch.setattr(cli, "_upload_voice_file", fake_upload) monkeypatch.setattr(cli, "_clone_voice_file", fake_clone) audio = tmp_path / "anchor.mp3" audio.write_bytes(b"fake") assert main(["voices", "add", str(audio)]) == 0 # first add: ok assert main(["voices", "add", str(audio)]) == 1 # second add: collision def test_voices_derive_name_replaces_spaces(): from voice_gen.cli import _derive_voice_name from pathlib import Path assert _derive_voice_name(Path("My Audio.mp3")) == "My_Audio" assert _derive_voice_name(Path("BroTsong-Saying-2026-06-10.m4a")) == "BroTsong-Saying-2026-06-10" assert _derive_voice_name(Path("录音.wav")) == "录音" def test_voices_pull_adds_missing(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) assert main(["init"]) == 0 from voice_gen import synthesizer server_payload = json.dumps({ "files": [ {"file_id": 111, "filename": "alice-2026-01-01.mp3", "bytes": 100, "created_at": 1}, {"file_id": 222, "filename": "bob-2026-01-15.mp3", "bytes": 200, "created_at": 2}, ] }) def fake_run(cmd, **kwargs): return type("R", (), {"returncode": 0, "stderr": "", "stdout": server_payload})() monkeypatch.setattr(synthesizer, "_run_mmx", fake_run) rc = main(["voices", "pull", "--auto"]) assert rc == 0 data = json.loads((tmp_path / "voices.json").read_text(encoding="utf-8")) assert set(data["voices"].keys()) == {"alice-2026-01-01", "bob-2026-01-15"} assert data["voices"]["alice-2026-01-01"]["file_id"] == 111 assert data["voices"]["bob-2026-01-15"]["file_id"] == 222 def test_voices_pull_skips_existing_without_force(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) assert main(["init"]) == 0 # Seed registry with one entry (tmp_path / "voices.json").write_text(json.dumps({ "voices": { "alice-2026-01-01": { "file_id": 999, "filename": "different-file.mp3", "created_at": "2026-01-01T00:00:00Z", } } }), encoding="utf-8") from voice_gen import synthesizer server_payload = json.dumps({ "files": [ {"file_id": 111, "filename": "alice-2026-01-01.mp3", "bytes": 100, "created_at": 1}, ] }) def fake_run(cmd, **kwargs): return type("R", (), {"returncode": 0, "stderr": "", "stdout": server_payload})() monkeypatch.setattr(synthesizer, "_run_mmx", fake_run) rc = main(["voices", "pull", "--auto"]) assert rc == 0 data = json.loads((tmp_path / "voices.json").read_text(encoding="utf-8")) # Original file_id preserved (no overwrite) assert data["voices"]["alice-2026-01-01"]["file_id"] == 999 def test_voices_pull_dry_run(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) assert main(["init"]) == 0 from voice_gen import synthesizer server_payload = json.dumps({ "files": [ {"file_id": 111, "filename": "alice.mp3", "bytes": 100, "created_at": 1}, ] }) def fake_run(cmd, **kwargs): return type("R", (), {"returncode": 0, "stderr": "", "stdout": server_payload})() monkeypatch.setattr(synthesizer, "_run_mmx", fake_run) rc = main(["voices", "pull", "--auto", "--dry-run"]) assert rc == 0 # voices.json should NOT have been written data = json.loads((tmp_path / "voices.json").read_text(encoding="utf-8")) assert data["voices"] == {} def test_voices_list_server(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) assert main(["init"]) == 0 from voice_gen import synthesizer server_payload = json.dumps({ "files": [ {"file_id": 111, "filename": "alice.mp3", "bytes": 100, "created_at": 1}, ] }) def fake_run(cmd, **kwargs): return type("R", (), {"returncode": 0, "stderr": "", "stdout": server_payload})() monkeypatch.setattr(synthesizer, "_run_mmx", fake_run) rc = main(["voices", "list", "server"]) assert rc == 0 # (output captured by capsys would normally be checked; just verify rc=0 here)