test: add regression tests for Live2D review blockers (#350)

* test: add regression tests for review blockers

- backup-clean --json actually deletes old backups
- flatten copies motion Sound assets
- save_model uses atomic write (tempfile + os.replace)
- auto_backup deduplicates on content, not wall-clock time
- save_model cleans up temp files on error

* test: use CliRunner in backup-clean regression test

The previous test deleted files manually, which would pass even if the
CLI regressed. Now it invokes 'backup-clean --keep 2 --json' through
CliRunner and asserts both disk state and JSON output.

---------

Co-authored-by: zyzly0705 <zyzly0705@users.noreply.github.com>
This commit is contained in:
zyzly0705
2026-06-14 16:50:41 +08:00
committed by GitHub
parent 4583ed246f
commit 43f29815a9
@@ -1,13 +1,17 @@
"""Unit tests for Live2D CLI core modules (no backend needed)."""
import json
import os
import time
import pytest
from pathlib import Path
from unittest.mock import patch
from cli_anything.live2d.core.parser import load_model, ModelInfo, MotionRef, ExpressionRef
from cli_anything.live2d.core.parser import load_model, save_model, ModelInfo, MotionRef, ExpressionRef
from cli_anything.live2d.core.validator import validate_model, ValidationResult
from cli_anything.live2d.core.scanner import scan_directory, find_model_files
from cli_anything.live2d.core.backup import snapshot, auto_backup, list_backups, _backup_dir_for
from click.testing import CliRunner
# ── Fixtures ────────────────────────────────────────────────────
@@ -179,3 +183,179 @@ class TestScanner:
files = find_model_files(model_dir)
assert len(files) == 1
assert files[0].suffix == ".json"
# ── Backup Tests ───────────────────────────────────────────────
class TestFlatten:
def test_flatten_copies_sound_assets(self, tmp_path):
"""Regression: flatten must copy motion Sound files, not just reference them."""
from cli_anything.live2d.live2d_cli import cli
model_dir = tmp_path / "character"
model_dir.mkdir()
# Create model with Sound reference
model_data = {
"Version": 3,
"FileReferences": {
"Moc": "model.moc3",
"Textures": ["textures/tex.png"],
"Motions": {
"idle": [
{
"File": "motions/idle.motion3.json",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Sound": "sounds/effect.wav",
}
]
},
},
}
model_file = model_dir / "test.model3.json"
model_file.write_text(json.dumps(model_data), encoding="utf-8")
# Create referenced files
(model_dir / "model.moc3").touch()
(model_dir / "textures").mkdir()
(model_dir / "textures" / "tex.png").touch()
(model_dir / "motions").mkdir()
(model_dir / "motions" / "idle.motion3.json").write_text("{}", encoding="utf-8")
(model_dir / "sounds").mkdir()
sound_file = model_dir / "sounds" / "effect.wav"
sound_file.write_bytes(b"RIFF....WAVE")
out_dir = tmp_path / "flat_output"
runner = CliRunner()
result = runner.invoke(cli, ["flatten", str(model_file), "-o", str(out_dir)])
assert result.exit_code == 0, f"flatten failed: {result.output}"
# Verify sound file was copied
assert (out_dir / "effect.wav").exists(), (
f"Sound file 'effect.wav' was not copied to output dir. "
f"Contents: {list(out_dir.iterdir()) if out_dir.exists() else 'dir missing'}"
)
# Verify model JSON references flat sound path
flat_model = json.loads((out_dir / "test.model3.json").read_text(encoding="utf-8"))
flat_motion = flat_model["FileReferences"]["Motions"]["idle"][0]
assert flat_motion["Sound"] == "effect.wav", (
f"Expected flat Sound path 'effect.wav', got '{flat_motion.get('Sound')}'"
)
class TestBackup:
def test_backup_clean_json_mode_deletes_files(self, sample_model3_file):
"""Regression: backup-clean --json must actually delete old backups, not just report."""
from cli_anything.live2d.live2d_cli import cli
from cli_anything.live2d.core.backup import _backup_dir_for
# Create 5 backups with distinct content
for i in range(5):
sample_model3_file.write_text(
json.dumps({"Version": i, "FileReferences": {}}), encoding="utf-8"
)
snapshot(sample_model3_file)
bdir = _backup_dir_for(sample_model3_file)
backups = sorted(bdir.glob("*.model3.json"), reverse=True)
assert len(backups) == 5
# Invoke backup-clean --keep 2 --json through the CLI
runner = CliRunner()
result = runner.invoke(cli, [
"--json", "backup-clean", str(sample_model3_file), "--keep", "2"
])
assert result.exit_code == 0, f"backup-clean failed: {result.output}"
# Verify only 2 backups remain on disk
remaining = sorted(bdir.glob("*.model3.json"), reverse=True)
assert len(remaining) == 2, f"Expected 2 backups after cleanup, got {len(remaining)}"
# Verify JSON output reports the deletion
out = json.loads(result.output)
assert out["delete"] == 3
assert out["total"] == 5
assert len(out["deleted"]) == 3
def test_auto_backup_skips_unchanged_content(self, sample_model3_file):
"""Regression: auto_backup skips when content is unchanged (not just <1s old)."""
# First backup
result1 = auto_backup(sample_model3_file)
assert result1 is not None
# Same content -> should skip
result2 = auto_backup(sample_model3_file)
assert result2 is None
backups = list_backups(sample_model3_file)
assert len(backups) == 1
def test_auto_backup_saves_changed_content(self, sample_model3_file):
"""Regression: auto_backup creates new backup when content changes."""
result1 = auto_backup(sample_model3_file)
assert result1 is not None
# Modify content
data = json.loads(sample_model3_file.read_text(encoding="utf-8"))
data["Version"] = 999
sample_model3_file.write_text(json.dumps(data), encoding="utf-8")
result2 = auto_backup(sample_model3_file)
assert result2 is not None
assert result2 != result1
backups = list_backups(sample_model3_file)
assert len(backups) == 2
def test_save_model_atomic(self, sample_model3_file):
"""Regression: save_model writes atomically via temp file + os.replace."""
info = load_model(sample_model3_file)
info.moc3 = "updated_model.moc3"
# Track os.replace calls to verify atomic write
original_replace = os.replace
replace_calls = []
def tracking_replace(src, dst):
replace_calls.append((str(src), str(dst)))
# Verify the temp file exists before replace
assert Path(src).exists(), f"Temp file {src} should exist before replace"
return original_replace(src, dst)
with patch("cli_anything.live2d.core.parser.os.replace", side_effect=tracking_replace):
save_model(info)
# Verify os.replace was called (atomic write)
assert len(replace_calls) == 1, "save_model should call os.replace exactly once"
src, dst = replace_calls[0]
assert ".tmp" in src or ".model3.json" in src
# Verify the file was actually updated
reloaded = load_model(sample_model3_file)
assert reloaded.moc3 == "updated_model.moc3"
def test_save_model_cleanup_on_error(self, sample_model3_file):
"""Regression: save_model cleans up temp file on error."""
info = load_model(sample_model3_file)
info.moc3 = "should_not_persist.moc3"
bdir = sample_model3_file.parent
tmp_files_before = set(bdir.glob(".*.tmp"))
# Force an error during write
with patch("cli_anything.live2d.core.parser.os.fdopen", side_effect=OSError("disk full")):
with pytest.raises(OSError, match="disk full"):
save_model(info)
tmp_files_after = set(bdir.glob(".*.tmp"))
new_tmps = tmp_files_after - tmp_files_before
assert len(new_tmps) == 0, f"Temp files not cleaned up: {new_tmps}"
# Original file should be unchanged
reloaded = load_model(sample_model3_file)
assert reloaded.moc3 != "should_not_persist.moc3"