"""transfer.py 核心逻辑测试:解析器 / 日志轮转 / 参数解析 / 后端命令构建""" import re import os import subprocess 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 # ---------- 补传重试 + 校验 ---------- def test_parse_args_retry(): args = transfer.parse_args(["--retry", "5", "s", "d"]) assert args.retry == 5 def test_parse_args_retry_default_zero(): assert transfer.parse_args(["s", "d"]).retry == 0 def test_parse_args_verify_flag(): args = transfer.parse_args(["--verify", "s", "d"]) assert args.verify is True def test_rsync_verify_cmd(): cmd = transfer.RsyncBackend.build_verify_cmd("a/", "b/", bwlimit="30000") joined = " ".join(cmd) assert cmd[0] == "rsync" assert "--checksum" in joined or "-c" in joined assert "--dry-run" in joined assert "--bwlimit=30000" in joined def test_rsync_verify_cmd_no_bwlimit(): cmd = transfer.RsyncBackend.build_verify_cmd("a/", "b/") assert "--bwlimit" not in " ".join(cmd) # ---------- 暂停/继续/停止 (TUI 命令) ---------- def _proc_state(pid: int) -> str: with open(f"/proc/{pid}/stat") as fh: return fh.read().split()[2] # 状态字段: R/S/T/D/Z def _make_runner(tmp_path) -> "transfer.TaskRunner": cfg = transfer.TransferConfig("s", "d") lm = transfer.LogManager(str(tmp_path), keep=20) return transfer.TaskRunner(cfg, lm) def test_pause_sends_sigstop(tmp_path): r = _make_runner(tmp_path) p = subprocess.Popen(["sleep", "30"], start_new_session=True) r.proc = p r.pause() assert r._paused is True assert _proc_state(p.pid) == "T" # T = stopped p.kill() p.wait() def test_resume_sends_sigcont(tmp_path): r = _make_runner(tmp_path) p = subprocess.Popen(["sleep", "30"], start_new_session=True) r.proc = p r.pause() r.resume() assert r._paused is False assert _proc_state(p.pid) in ("S", "R") # 已恢复 p.kill() p.wait() def test_pause_idempotent_when_no_proc(tmp_path): r = _make_runner(tmp_path) r.pause() # 无进程不报错 assert r._paused is False # ---------- 文件计数 (单文件进度条数据源) ---------- def test_count_files_recursive(tmp_path): (tmp_path / "a.flac").write_text("x") (tmp_path / "sub").mkdir() (tmp_path / "sub" / "b.lrc").write_text("y") (tmp_path / "sub" / "c.flac").write_text("z") assert transfer.count_files(str(tmp_path)) == 3 def test_count_files_missing_returns_zero(): assert transfer.count_files("/nonexistent/xyz") == 0 def test_count_files_only_files(tmp_path): (tmp_path / "a.txt").write_text("x") (tmp_path / "dir1").mkdir() assert transfer.count_files(str(tmp_path)) == 1 # 目录不计 def test_parse_args_retry_infinite(): args = transfer.parse_args(["--retry", "-1", "s", "d"]) assert args.retry == -1 # 无限重试