2026-08-25 12:31:13 +01:00
|
|
|
import io
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
|
|
|
|
|
|
|
|
|
|
import rsync_progress # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class NotATerminal(io.StringIO):
|
|
|
|
|
def isatty(self):
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Terminal(io.StringIO):
|
|
|
|
|
def isatty(self):
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
2026-08-25 12:40:32 +01:00
|
|
|
def run(lines, total=0, out=None, bytes_expected=0):
|
2026-08-25 12:31:13 +01:00
|
|
|
out = out or NotATerminal()
|
2026-08-25 12:40:32 +01:00
|
|
|
rsync_progress.main(
|
|
|
|
|
["--total", str(total), "--bytes", str(bytes_expected)],
|
|
|
|
|
stream=io.StringIO(lines),
|
|
|
|
|
out=out,
|
|
|
|
|
)
|
2026-08-25 12:31:13 +01:00
|
|
|
return out.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_artist_and_album_are_pulled_from_the_path():
|
|
|
|
|
assert rsync_progress.album_of("Pendulum/Immersion/01 - Watercolour.mp3") == (
|
|
|
|
|
"Pendulum / Immersion"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_shallower_path_degrades_rather_than_failing():
|
|
|
|
|
assert rsync_progress.album_of("Pendulum/loose.mp3") == "Pendulum"
|
|
|
|
|
assert rsync_progress.album_of("loose.mp3") == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_directories_are_not_counted():
|
|
|
|
|
"""rsync reports them too, and counting them puts the percentage past 100."""
|
|
|
|
|
output = run("Artist/\nArtist/Album/\nArtist/Album/track.mp3\n", total=1)
|
|
|
|
|
|
2026-08-25 12:40:32 +01:00
|
|
|
assert "1/1" in output
|
2026-08-25 12:31:13 +01:00
|
|
|
assert "100%" in output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_percentage_tracks_the_total():
|
|
|
|
|
output = run("".join(f"A/B/{i}.mp3\n" for i in range(5)), total=10)
|
|
|
|
|
|
2026-08-25 12:40:32 +01:00
|
|
|
assert "5/10" in output
|
|
|
|
|
assert "50%" in output
|
2026-08-25 12:31:13 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_without_a_total_it_counts_instead_of_guessing():
|
|
|
|
|
output = run("A/B/one.mp3\nA/B/two.mp3\n")
|
|
|
|
|
|
|
|
|
|
assert "2 files" in output
|
|
|
|
|
assert "%" not in output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_final_line_is_always_printed():
|
|
|
|
|
"""Otherwise the last state of a rewriting line is whatever it happened to
|
|
|
|
|
be when the interval last elapsed."""
|
|
|
|
|
output = run("A/B/one.mp3\n", total=1)
|
|
|
|
|
|
|
|
|
|
assert output.endswith("\n")
|
2026-08-25 12:40:32 +01:00
|
|
|
assert "1/1" in output
|
2026-08-25 12:31:13 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_nothing_transferred_still_reports():
|
|
|
|
|
output = run("", total=0)
|
|
|
|
|
|
|
|
|
|
assert "0 files" in output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_log_gets_no_carriage_returns():
|
|
|
|
|
"""A non-terminal filling with \\r and escape codes is unreadable."""
|
|
|
|
|
output = run("".join(f"A/B/{i}.mp3\n" for i in range(50)), total=50)
|
|
|
|
|
|
|
|
|
|
assert "\r" not in output
|
|
|
|
|
assert "\033" not in output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_terminal_rewrites_one_line():
|
|
|
|
|
output = run("".join(f"A/B/{i}.mp3\n" for i in range(50)), total=50, out=Terminal())
|
|
|
|
|
|
|
|
|
|
assert "\r\033[2K" in output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("text", "width", "expected"),
|
|
|
|
|
[
|
|
|
|
|
("short", 20, "short"),
|
|
|
|
|
("King Gizzard / PetroDragonic Apocalypse", 20, "…agonic Apocalypse"),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_long_labels_are_trimmed_from_the_left(text, width, expected):
|
|
|
|
|
"""The album is the informative end, so the artist is what gets cut."""
|
|
|
|
|
trimmed = rsync_progress.fit(text, width)
|
|
|
|
|
|
|
|
|
|
assert len(trimmed) <= width
|
|
|
|
|
if len(text) > width:
|
|
|
|
|
assert trimmed.startswith("…")
|
|
|
|
|
assert text.endswith(trimmed.lstrip("…"))
|
2026-08-25 12:40:32 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_size_and_path_are_parsed():
|
|
|
|
|
assert rsync_progress.parse("5000 Artist/Album/Track.mp3\n") == (
|
|
|
|
|
5000,
|
|
|
|
|
"Artist/Album/Track.mp3",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_filename_containing_spaces_survives():
|
|
|
|
|
"""Splitting on every space would lose most of the library."""
|
|
|
|
|
assert rsync_progress.parse("1234 Artist/An Album/A Track With Spaces.mp3") == (
|
|
|
|
|
1234,
|
|
|
|
|
"Artist/An Album/A Track With Spaces.mp3",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_bare_path_is_tolerated():
|
|
|
|
|
"""In case this is fed --out-format='%n' by something older."""
|
|
|
|
|
assert rsync_progress.parse("Artist/Album/Track.mp3") == (0, "Artist/Album/Track.mp3")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_directory_sizes_do_not_inflate_the_total():
|
|
|
|
|
"""rsync reports directories with a 4096 inode size, which is several
|
|
|
|
|
megabytes of nothing across six thousand albums."""
|
|
|
|
|
output = run("4096 Artist/\n4096 Artist/Album/\n5000 Artist/Album/t.mp3\n", total=1)
|
|
|
|
|
|
|
|
|
|
assert "4.9 KiB" in output
|
|
|
|
|
assert "12" not in output.split("Artist")[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_rate_is_not_reported_until_it_means_something():
|
|
|
|
|
"""The first files arrive microseconds apart and would give a rate in the
|
|
|
|
|
gigabytes per second and an ETA of zero."""
|
|
|
|
|
rate = rsync_progress.Rate()
|
|
|
|
|
rate.add(100.0, 0)
|
|
|
|
|
rate.add(100.5, 5_000_000)
|
|
|
|
|
|
|
|
|
|
assert rate.per_second() == 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_rate_over_a_long_enough_window_is_reported():
|
|
|
|
|
rate = rsync_progress.Rate()
|
|
|
|
|
rate.add(100.0, 0)
|
|
|
|
|
rate.add(110.0, 10_000_000)
|
|
|
|
|
|
|
|
|
|
assert rate.per_second() == pytest.approx(1_000_000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_window_forgets_the_distant_past():
|
|
|
|
|
"""So the estimate follows a device that slows down rather than averaging
|
|
|
|
|
the slowdown away."""
|
|
|
|
|
rate = rsync_progress.Rate(window=30.0)
|
|
|
|
|
for second in range(0, 100, 10):
|
|
|
|
|
rate.add(float(second), second * 1_000_000)
|
|
|
|
|
rate.add(200.0, 100_000_000)
|
|
|
|
|
|
|
|
|
|
assert rate.samples[0][0] >= 90.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("seconds", "expected"),
|
|
|
|
|
[(0, "0s"), (45, "45s"), (60, "1m00s"), (1092, "18m12s"), (7500, "2h05m")],
|
|
|
|
|
)
|
|
|
|
|
def test_durations_read_without_arithmetic(seconds, expected):
|
|
|
|
|
assert rsync_progress.human_duration(seconds) == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_summary_is_printed_at_the_end():
|
|
|
|
|
output = run("5000000 A/B/one.mp3\n", total=1, bytes_expected=5000000)
|
|
|
|
|
|
|
|
|
|
assert "copied 4.8 MiB in" in output
|