Files
Cursor Agentandrmcrackan 28e5ed3be2 test(cli): cover the per-run download limit
Two levels. The pure tests supply a run's history directly and check the
decision to stop: the count and size thresholds, the always-allow-one rule,
and that titles another process downloaded, or that this run attempted
without downloading, are not counted.

The loop tests drive the real run loop against a real library database with
a processable that records downloads the way DownloadDecryptBook does, which
is as close to a limited run as is possible without an Audible account. They
pin the behavior that matters at the boundary: five books under a limit of
two download two, and a run whose books end exactly at its limit reports
nothing, because nothing was cut short.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 19:52:29 +00:00

240 lines
7.6 KiB
C#

using AssertionHelper;
using LibationFileManager;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace LibationCli.Tests;
/// <summary>
/// The per-run limit options on <c>liberate</c>, and the decision to stop a run. Everything here is pure: the
/// history a run has produced is supplied directly, so no database or downloading is involved.
/// </summary>
[TestClass]
public class RunDownloadLimitTests
{
private static readonly DateTimeOffset RunStart = new(2026, 8, 14, 19, 0, 0, TimeSpan.FromHours(-4));
private const long ThreeHundredMB = 300L * 1024 * 1024;
private static LiberateOptions? Parse(params string[] args)
{
using var error = new StringWriter();
return Program.ParseInvocation(args, error).Result?.Value as LiberateOptions;
}
private static DownloadHistoryEntry Entry(string asin, long bytes = ThreeHundredMB)
=> new(RunStart.AddMinutes(1), asin, IsAudiblePlus: true, bytes);
#region parsing
[TestMethod]
[DataRow("--limit-books", 10, Configuration.DailyLimitUnit.Books)]
[DataRow("--limit-mb", 500, Configuration.DailyLimitUnit.MB)]
[DataRow("--limit-gb", 5, Configuration.DailyLimitUnit.GB)]
public void Each_option_maps_to_its_own_unit(string option, int quantity, Configuration.DailyLimitUnit expectedUnit)
{
var options = Parse("liberate", option, quantity.ToString());
Assert.IsNotNull(options);
Assert.IsTrue(RunDownloadLimit.TryCreate(options.LimitBooks, options.LimitMB, options.LimitGB, options.PdfOnly, out var limit, out var error));
Assert.IsNull(error);
Assert.IsNotNull(limit);
Assert.AreEqual(expectedUnit, limit.Value.Unit);
limit.Value.Quantity.Should().Be(quantity);
}
[TestMethod]
[DataRow("--limit-books", "10", "--limit-mb", "500")]
[DataRow("--limit-books", "10", "--limit-gb", "5")]
[DataRow("--limit-mb", "500", "--limit-gb", "5")]
public void Two_limit_options_cannot_be_used_together(params string[] limitArgs)
{
using var error = new StringWriter();
var outcome = Program.ParseInvocation(["liberate", .. limitArgs], error);
Assert.AreEqual(ExitCode.ParseError, outcome.ExitCode);
StringAssert.Contains(error.ToString(), "is not compatible with");
}
[TestMethod]
public void A_limit_combines_with_the_other_liberate_options()
{
var options = Parse("liberate", "--force", "--limit-books", "3", "B017V4IM1G");
Assert.IsNotNull(options);
options.Force.Should().BeTrue();
Assert.AreEqual(3, options.LimitBooks);
CollectionAssert.AreEqual(new[] { "B017V4IM1G" }, options.Asins!.ToArray());
}
[TestMethod]
public void No_limit_option_is_an_unlimited_run_rather_than_an_error()
{
var options = Parse("liberate");
Assert.IsNotNull(options);
Assert.IsTrue(RunDownloadLimit.TryCreate(options.LimitBooks, options.LimitMB, options.LimitGB, options.PdfOnly, out var limit, out var error));
Assert.IsNull(limit);
Assert.IsNull(error);
}
#endregion
#region validation
[TestMethod]
[DataRow(0)]
[DataRow(-7)]
public void A_quantity_below_one_is_rejected(int quantity)
{
Assert.IsFalse(RunDownloadLimit.TryCreate(quantity, null, null, pdfOnly: false, out var limit, out var error));
Assert.IsNull(limit);
StringAssert.Contains(error!, "--limit-books");
StringAssert.Contains(error!, "at least 1");
}
[TestMethod]
public void The_rejection_names_the_option_the_user_actually_typed()
{
Assert.IsFalse(RunDownloadLimit.TryCreate(null, null, 0, pdfOnly: false, out _, out var error));
StringAssert.Contains(error!, "--limit-gb");
}
[TestMethod]
public void A_limit_is_rejected_with_pdf_only_rather_than_silently_never_stopping()
{
Assert.IsFalse(RunDownloadLimit.TryCreate(null, 500, null, pdfOnly: true, out var limit, out var error));
Assert.IsNull(limit);
StringAssert.Contains(error!, "--limit-mb");
StringAssert.Contains(error!, "--pdf");
}
#endregion
#region stopping a run
[TestMethod]
public void A_book_limit_stops_the_run_once_that_many_have_downloaded()
{
var history = new List<DownloadHistoryEntry>();
var tracker = new RunLimitTracker(new(Configuration.DailyLimitUnit.Books, 2), RunStart, _ => history);
tracker.TryStop(out _).Should().BeFalse();
Download(tracker, history, "ASIN1");
tracker.TryStop(out _).Should().BeFalse();
Download(tracker, history, "ASIN2");
tracker.TryStop(out var message).Should().BeTrue();
StringAssert.Contains(message!, "2 book(s)");
StringAssert.Contains(message!, "Downloaded 2 title(s)");
StringAssert.Contains(message!, "tried on the next run");
}
[TestMethod]
public void A_size_limit_stops_when_another_book_would_not_fit()
{
// 300 MB each against a 1 GB limit: a fourth would be assumed to need another 400 MB, over the limit.
var history = new List<DownloadHistoryEntry>();
var tracker = new RunLimitTracker(new(Configuration.DailyLimitUnit.GB, 1), RunStart, _ => history);
Download(tracker, history, "ASIN1");
Download(tracker, history, "ASIN2");
tracker.TryStop(out _).Should().BeFalse();
Download(tracker, history, "ASIN3");
tracker.TryStop(out var message).Should().BeTrue();
StringAssert.Contains(message!, "1 GB");
StringAssert.Contains(message!, "900 MB");
StringAssert.Contains(message!, "across 3 title(s)");
}
[TestMethod]
public void One_download_is_allowed_even_when_the_limit_is_smaller_than_a_book()
{
var history = new List<DownloadHistoryEntry>();
var tracker = new RunLimitTracker(new(Configuration.DailyLimitUnit.MB, 1), RunStart, _ => history);
// Otherwise a run would end immediately and report a limit reached to someone who downloaded nothing.
tracker.TryStop(out _).Should().BeFalse();
Download(tracker, history, "ASIN1");
tracker.TryStop(out _).Should().BeTrue();
}
[TestMethod]
public void Downloads_this_run_did_not_perform_are_not_counted()
{
// A Libation window or a second container downloading at the same time writes to the same history.
var history = new List<DownloadHistoryEntry>
{
Entry("SOMEONE_ELSE1"),
Entry("SOMEONE_ELSE2"),
Entry("SOMEONE_ELSE3")
};
var tracker = new RunLimitTracker(new(Configuration.DailyLimitUnit.Books, 2), RunStart, _ => history);
Download(tracker, history, "ASIN1");
tracker.TryStop(out _).Should().BeFalse();
}
[TestMethod]
public void Titles_attempted_but_not_downloaded_are_not_counted()
{
// A title that failed, or that the daily limit skipped, writes no history row.
var history = new List<DownloadHistoryEntry>();
var tracker = new RunLimitTracker(new(Configuration.DailyLimitUnit.Books, 1), RunStart, _ => history);
tracker.Attempting("FAILED1");
tracker.Attempting("FAILED2");
tracker.TryStop(out _).Should().BeFalse();
}
[TestMethod]
public void Product_ids_are_matched_without_regard_to_case()
{
var history = new List<DownloadHistoryEntry> { Entry("b0test0001") };
var tracker = new RunLimitTracker(new(Configuration.DailyLimitUnit.Books, 1), RunStart, _ => history);
tracker.Attempting("B0TEST0001");
tracker.TryStop(out _).Should().BeTrue();
}
[TestMethod]
public void History_is_read_afresh_from_the_start_of_the_run()
{
// Re-reading is what lets the limit reflect what the last title actually weighed.
var reads = new List<DateTimeOffset>();
var tracker = new RunLimitTracker(
new(Configuration.DailyLimitUnit.Books, 5),
RunStart,
since => { reads.Add(since); return []; });
tracker.Attempting("ASIN1");
tracker.TryStop(out _);
tracker.TryStop(out _);
CollectionAssert.AreEqual(new[] { RunStart, RunStart }, reads);
}
private static void Download(RunLimitTracker tracker, List<DownloadHistoryEntry> history, string asin)
{
tracker.Attempting(asin);
history.Add(Entry(asin));
}
#endregion
}