336 lines
11 KiB
Python
336 lines
11 KiB
Python
# 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)
|