Files
TransferSu/tests/test_transfer.py
T

285 lines
10 KiB
Python

"""transfer.py 核心逻辑测试:解析器 / 日志轮转 / 参数解析 / 后端命令构建"""
import re
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import transfer
# ---------- 解析器: rsync ----------
def test_rsync_progress2_line():
line = " 31,457,280 12% 356.77MB/s 0:00:00 (xfr#1, to-chk=51/53)"
s = transfer.RsyncParser.feed_progress2(line)
assert s is not None
assert s.pct == 12
assert s.bytes_done == 31457280
assert s.files_done == 1
assert s.files_total == 53
assert s.speed_bps > 0
def test_rsync_progress2_complete():
line = " 243,401,728 100% 305.39MB/s 0:00:00 (xfr#52, to-chk=0/53)"
s = transfer.RsyncParser.feed_progress2(line)
assert s is not None
assert s.pct == 100
assert s.files_done == 52
assert s.files_total == 53
def test_rsync_progress2_unparseable_returns_none():
assert transfer.RsyncParser.feed_progress2("building file list") is None
assert transfer.RsyncParser.feed_progress2("") is None
def test_rsync_single_file_line():
line = "file_1.bin 31,457,280 100% 356.77MB/s 0:00:00"
f = transfer.RsyncParser.feed_file(line)
assert f is not None
assert f.name == "file_1.bin"
assert f.pct == 100
def test_rsync_error_detection():
assert transfer.RsyncParser.is_error("rsync: [sender] write error: Broken pipe (32)")
assert transfer.RsyncParser.is_error("rsync error: some files/attrs were not transferred (code 23)")
assert not transfer.RsyncParser.is_error("file_1.bin 100%")
assert not transfer.RsyncParser.is_error("")
# ---------- 解析器: rclone ----------
def test_rclone_file_line():
line = " * file_7.bin: 98% / 7.023 MiB, 2.715 MiB/s"
f = transfer.RcloneParser.feed_file(line)
assert f is not None
assert f.name == "file_7.bin"
assert f.pct == 98
def test_rclone_total_line():
line = "Transferred: 232.126 MiB / 232.126 MiB, 100%, 7.997 MiB/s, ETA 0s"
s = transfer.RcloneParser.feed_total(line)
assert s is not None
assert s.pct == 100
def test_rclone_files_count_line():
line = "Transferred: 52 / 52, 100%"
s = transfer.RcloneParser.feed_files(line)
assert s is not None
assert s.files_done == 52
assert s.files_total == 52
# ---------- 日志轮转 ----------
def test_logmanager_creates_and_appends(tmp_path):
lm = transfer.LogManager(tmp_path, keep=20)
path = lm.open_session("test")
assert os.path.exists(path)
lm.write("hello")
lm.write("world")
content = open(path).read()
assert "hello" in content
assert "world" in content
assert content.index("hello") < content.index("world") # 追加顺序正确
def test_logmanager_keeps_only_latest_20(tmp_path):
lm = transfer.LogManager(tmp_path, keep=20)
for i in range(25):
lm.open_session(f"run{i}")
lm.write(f"data{i}")
lm.prune()
files = sorted(f for f in os.listdir(tmp_path) if f.endswith(".log"))
assert len(files) == 20
run_nums = [int(re.search(r"run(\d+)", f).group(1)) for f in files]
assert 5 in run_nums
assert 4 not in run_nums
def test_logmanager_filenames_sorted_chronologically(tmp_path):
lm = transfer.LogManager(tmp_path, keep=20)
p1 = lm.open_session("a")
p2 = lm.open_session("b")
assert p1 != p2
assert p2 > p1 # 时间戳递增
# ---------- 参数解析 ----------
def test_parse_args_with_positional():
args = transfer.parse_args(["src", "dst"])
assert args.source == "src"
assert args.dest == "dst"
assert args.backend == "rsync"
assert args.keep_logs == 20
assert args.mode == "tui" # 未显式指定 cli 时默认 TUI
def test_parse_args_explicit_cli():
args = transfer.parse_args(["--mode", "cli", "src", "dst"])
assert args.mode == "cli"
def test_parse_args_no_positional_goes_tui():
args = transfer.parse_args([])
assert args.mode == "tui"
assert args.source is None
def test_parse_args_full_options():
args = transfer.parse_args([
"--backend", "rclone", "--bwlimit", "30M", "--resume",
"--checksum", "--mode", "cli", "--keep-logs", "5",
"--logdir", "/tmp/x", "src", "dst",
])
assert args.backend == "rclone"
assert args.bwlimit == "30M"
assert args.resume is True
assert args.checksum is True
assert args.keep_logs == 5
assert args.logdir == "/tmp/x"
# ---------- 后端命令构建 ----------
def test_rsync_backend_cmd():
cmd = transfer.RsyncBackend.build_cmd("a/", "b/", bwlimit="30000", resume=True, checksum=False)
assert cmd[0] == "rsync"
joined = " ".join(cmd)
assert "--info=progress2" in joined
assert "--bwlimit=30000" in joined
assert "--partial" in joined
assert "--append" in joined
assert "-a" in joined
def test_rsync_backend_checksum_flag():
cmd = transfer.RsyncBackend.build_cmd("a/", "b/", bwlimit="", resume=False, checksum=True)
assert "--checksum" in " ".join(cmd)
def test_rclone_backend_cmd():
cmd = transfer.RcloneBackend.build_cmd("a/", "b/", bwlimit="8M", transfers=4)
assert cmd[0] == "rclone"
joined = " ".join(cmd)
assert "--bwlimit" in joined
assert "--transfers" in joined
assert "--progress" in joined
def test_tar_pv_backend_cmd():
cmd = transfer.TarPvBackend.build_cmd("a/", "b/")
assert "tar" in cmd
assert "pv" in cmd
assert "|" in cmd # 管道串接
# ---------- 冲突策略: 参数 ----------
def test_policy_default_overwrite():
args = transfer.parse_args(["src", "dst"])
assert args.policy == "overwrite"
def test_policy_skip_flag():
args = transfer.parse_args(["--policy", "skip", "src", "dst"])
assert args.policy == "skip"
def test_policy_ask_flag():
args = transfer.parse_args(["--policy", "ask", "src", "dst"])
assert args.policy == "ask"
def test_policy_synonyms():
assert transfer.parse_args(["--overwrite", "s", "d"]).policy == "overwrite"
assert transfer.parse_args(["--skip", "s", "d"]).policy == "skip"
assert transfer.parse_args(["--ask", "s", "d"]).policy == "ask"
# ---------- 冲突策略: 扫描解析 ----------
def test_scan_rsync_new_file():
conflicts = transfer.ConflictScanner.parse_rsync_line(">f+++++++++|4|new.txt")
assert conflicts == ("new.txt", "new")
def test_scan_rsync_update():
conflicts = transfer.ConflictScanner.parse_rsync_line(">f.st......|123|f.txt")
assert conflicts == ("f.txt", "update")
def test_scan_rsync_metadata_only_update():
conflicts = transfer.ConflictScanner.parse_rsync_line(">f..t......|0|same.txt")
assert conflicts == ("same.txt", "update")
def test_scan_rsync_ignore_non_file_lines():
assert transfer.ConflictScanner.parse_rsync_line("cd+++++++++|0|dir/") is None
assert transfer.ConflictScanner.parse_rsync_line("*deleting |0|old.txt") is None
assert transfer.ConflictScanner.parse_rsync_line("") is None
# ---------- 冲突策略: 决策 ----------
def test_apply_policy_overwrite():
conflicts = [("a.txt", "update"), ("b.txt", "update")]
skipped = transfer.apply_policy(conflicts, "overwrite", {})
assert skipped == []
def test_apply_policy_skip():
conflicts = [("a.txt", "update"), ("b.txt", "update")]
skipped = transfer.apply_policy(conflicts, "skip", {})
assert set(skipped) == {"a.txt", "b.txt"}
def test_apply_policy_ask_mixed_answers():
conflicts = [("a.txt", "update"), ("b.txt", "update")]
answers = {"a.txt": "y", "b.txt": "n"}
skipped = transfer.apply_policy(conflicts, "ask", answers)
assert skipped == ["b.txt"] # 仅跳过选 n 的
def test_apply_policy_ask_all_overwrite():
conflicts = [("a.txt", "update")]
skipped = transfer.apply_policy(conflicts, "ask", {"*": "a"})
assert skipped == []
def test_apply_policy_ask_all_skip():
conflicts = [("a.txt", "update"), ("b.txt", "update")]
skipped = transfer.apply_policy(conflicts, "ask", {"*": "s"})
assert set(skipped) == {"a.txt", "b.txt"}
# ---------- 后端 exclude 集成 ----------
def test_rsync_backend_exclude_from():
cmd = transfer.RsyncBackend.build_cmd("a/", "b/", exclude_from="/tmp/excl.txt")
assert "--exclude-from=/tmp/excl.txt" in cmd
def test_rclone_backend_skip_policy():
cmd = transfer.RcloneBackend.build_cmd("a/", "b/", skip_existing=True)
assert "--ignore-existing" in " ".join(cmd)
def test_rsync_backend_no_append_when_overwriting():
"""覆盖场景禁用 --append (追加模式会阻止重写)"""
cmd = transfer.RsyncBackend.build_cmd("a/", "b/", resume=True, append=False)
assert "--append" not in cmd
assert "--partial" in cmd
def test_apply_policy_global_skip_keeps_explicit_yes():
"""先选 y 再选 s: 已确认覆盖的保留, 其余跳过"""
conflicts = [("a.txt", "update"), ("b.txt", "update")]
answers = {"a.txt": "y", "*": "s"}
skipped = transfer.apply_policy(conflicts, "ask", answers)
assert skipped == ["b.txt"]
def test_apply_policy_global_overwrite_keeps_explicit_no():
"""先选 n 再选 a: 已确认跳过的保留, 其余覆盖"""
conflicts = [("a.txt", "update"), ("b.txt", "update")]
answers = {"a.txt": "n", "*": "a"}
skipped = transfer.apply_policy(conflicts, "ask", answers)
assert skipped == ["a.txt"]
# ---------- 速度统计 ----------
def test_speed_stats_basic():
h = [10 * 1024 * 1024, 20 * 1024 * 1024, 15 * 1024 * 1024]
cur, peak, avg = transfer.speed_stats(h)
assert cur == 15 * 1024 * 1024
assert peak == 20 * 1024 * 1024
assert avg == 15 * 1024 * 1024
def test_speed_stats_empty():
assert transfer.speed_stats([]) == (0.0, 0.0, 0.0)
def test_speed_stats_single():
cur, peak, avg = transfer.speed_stats([8 * 1024 * 1024])
assert (cur, peak, avg) == (8 * 1024 * 1024, 8 * 1024 * 1024, 8 * 1024 * 1024)
# ---------- 目录大小轮询 (rsync 输出缓冲替代方案) ----------
def test_dir_size_returns_bytes(tmp_path):
"""du 目录返回字节数"""
(tmp_path / "a.bin").write_bytes(b"x" * 1000)
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "b.bin").write_bytes(b"y" * 500)
assert transfer.dir_size(str(tmp_path)) == 1500
def test_dir_size_missing_returns_zero():
assert transfer.dir_size("/nonexistent/path/xyz") == 0