skills-vault/skills/voice-generation/tests/test_cli.py

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)