refactor: convert to file-scoped namespaces

pull/47/head
Robert Dailey 2 years ago
parent e67b4296b3
commit 593740900a

@ -3,61 +3,60 @@ using Common.Extensions;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
namespace Common.Tests.Extensions namespace Common.Tests.Extensions;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class DictionaryExtensionsTest
{ {
[TestFixture] private class MySampleValue
[Parallelizable(ParallelScope.All)]
public class DictionaryExtensionsTest
{ {
private class MySampleValue }
{
} [Test]
public void Create_item_if_none_exists()
[Test] {
public void Create_item_if_none_exists() var dict = new Dictionary<int, MySampleValue>();
{ var theValue = dict.GetOrCreate(100);
var dict = new Dictionary<int, MySampleValue>(); dict.Should().HaveCount(1);
var theValue = dict.GetOrCreate(100); dict.Should().Contain(100, theValue);
dict.Should().HaveCount(1); }
dict.Should().Contain(100, theValue);
} [Test]
public void Return_default_if_no_item_exists()
[Test] {
public void Return_default_if_no_item_exists() var sample = new MySampleValue();
{ var dict = new Dictionary<int, MySampleValue> {{100, sample}};
var sample = new MySampleValue();
var dict = new Dictionary<int, MySampleValue> {{100, sample}}; var theValue = dict.GetOrDefault(200);
var theValue = dict.GetOrDefault(200); dict.Should().HaveCount(1).And.Contain(100, sample);
theValue.Should().BeNull();
dict.Should().HaveCount(1).And.Contain(100, sample); }
theValue.Should().BeNull();
} [Test]
public void Return_existing_item_if_exists_not_create()
[Test] {
public void Return_existing_item_if_exists_not_create() var sample = new MySampleValue();
{ var dict = new Dictionary<int, MySampleValue> {{100, sample}};
var sample = new MySampleValue();
var dict = new Dictionary<int, MySampleValue> {{100, sample}}; var theValue = dict.GetOrCreate(100);
dict.Should().HaveCount(1);
var theValue = dict.GetOrCreate(100); dict.Should().Contain(100, sample);
dict.Should().HaveCount(1); dict.Should().ContainValue(theValue);
dict.Should().Contain(100, sample); theValue.Should().Be(sample);
dict.Should().ContainValue(theValue); }
theValue.Should().Be(sample);
} [Test]
public void Return_existing_item_if_it_exists_not_default()
[Test] {
public void Return_existing_item_if_it_exists_not_default() var sample = new MySampleValue();
{ var dict = new Dictionary<int, MySampleValue> {{100, sample}};
var sample = new MySampleValue();
var dict = new Dictionary<int, MySampleValue> {{100, sample}}; var theValue = dict.GetOrDefault(100);
var theValue = dict.GetOrDefault(100); // Ensure the container hasn't been mutated
dict.Should().HaveCount(1).And.Contain(100, sample);
// Ensure the container hasn't been mutated theValue.Should().Be(sample);
dict.Should().HaveCount(1).And.Contain(100, sample);
theValue.Should().Be(sample);
}
} }
} }

@ -2,37 +2,36 @@
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
namespace Common.Tests namespace Common.Tests;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ResourceDataReaderTest
{ {
[TestFixture] [Test]
[Parallelizable(ParallelScope.All)] public void GetResourceData_DefaultDir_ReturnResourceData()
public class ResourceDataReaderTest
{ {
[Test] var testData = new ResourceDataReader(typeof(ResourceDataReaderTest));
public void GetResourceData_DefaultDir_ReturnResourceData() var data = testData.ReadData("DefaultDataFile.txt");
{ data.Trim().Should().Be("DefaultDataFile");
var testData = new ResourceDataReader(typeof(ResourceDataReaderTest)); }
var data = testData.ReadData("DefaultDataFile.txt");
data.Trim().Should().Be("DefaultDataFile");
}
[Test] [Test]
public void GetResourceData_NonexistentFile_Throw() public void GetResourceData_NonexistentFile_Throw()
{ {
var testData = new ResourceDataReader(typeof(ResourceDataReaderTest)); var testData = new ResourceDataReader(typeof(ResourceDataReaderTest));
Action act = () => testData.ReadData("DataFileWontBeFound.txt"); Action act = () => testData.ReadData("DataFileWontBeFound.txt");
act.Should() act.Should()
.Throw<ArgumentException>() .Throw<ArgumentException>()
.WithMessage("Embedded resource not found*"); .WithMessage("Embedded resource not found*");
} }
[Test] [Test]
public void ReadData_ExplicitSubDir_ReturnResourceData() public void ReadData_ExplicitSubDir_ReturnResourceData()
{ {
var testData = new ResourceDataReader(typeof(ResourceDataReaderTest), "TestData"); var testData = new ResourceDataReader(typeof(ResourceDataReaderTest), "TestData");
var data = testData.ReadData("DataFile.txt"); var data = testData.ReadData("DataFile.txt");
data.Trim().Should().Be("DataFile"); data.Trim().Should().Be("DataFile");
}
} }
} }

@ -2,37 +2,36 @@ using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
namespace Common.Extensions namespace Common.Extensions;
public static class CollectionExtensions
{ {
public static class CollectionExtensions // From: https://stackoverflow.com/a/34362585/157971
public static IReadOnlyCollection<T> AsReadOnly<T>(this ICollection<T> source)
{ {
// From: https://stackoverflow.com/a/34362585/157971 if (source is null)
public static IReadOnlyCollection<T> AsReadOnly<T>(this ICollection<T> source)
{ {
if (source is null) throw new ArgumentNullException(nameof(source));
{
throw new ArgumentNullException(nameof(source));
}
return source as IReadOnlyCollection<T> ?? new ReadOnlyCollectionAdapter<T>(source);
} }
// From: https://stackoverflow.com/a/34362585/157971 return source as IReadOnlyCollection<T> ?? new ReadOnlyCollectionAdapter<T>(source);
private sealed class ReadOnlyCollectionAdapter<T> : IReadOnlyCollection<T> }
{
private readonly ICollection<T> _source;
public ReadOnlyCollectionAdapter(ICollection<T> source) => _source = source;
public int Count => _source.Count;
public IEnumerator<T> GetEnumerator() => _source.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public static void AddRange<T>(this ICollection<T> destination, IEnumerable<T> source) // From: https://stackoverflow.com/a/34362585/157971
private sealed class ReadOnlyCollectionAdapter<T> : IReadOnlyCollection<T>
{
private readonly ICollection<T> _source;
public ReadOnlyCollectionAdapter(ICollection<T> source) => _source = source;
public int Count => _source.Count;
public IEnumerator<T> GetEnumerator() => _source.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public static void AddRange<T>(this ICollection<T> destination, IEnumerable<T> source)
{
foreach (var item in source)
{ {
foreach (var item in source) destination.Add(item);
{
destination.Add(item);
}
} }
} }
} }

@ -1,24 +1,23 @@
using System.Collections.Generic; using System.Collections.Generic;
namespace Common.Extensions namespace Common.Extensions;
public static class DictionaryExtensions
{ {
public static class DictionaryExtensions public static TValue GetOrCreate<TKey, TValue>(this IDictionary<TKey, TValue> dict, TKey key)
where TValue : new()
{ {
public static TValue GetOrCreate<TKey, TValue>(this IDictionary<TKey, TValue> dict, TKey key) if (!dict.TryGetValue(key, out var val))
where TValue : new()
{ {
if (!dict.TryGetValue(key, out var val)) val = new TValue();
{ dict.Add(key, val);
val = new TValue();
dict.Add(key, val);
}
return val;
} }
public static TValue? GetOrDefault<TKey, TValue>(this IDictionary<TKey, TValue> dict, TKey key) return val;
{ }
return dict.TryGetValue(key, out var val) ? val : default;
} public static TValue? GetOrDefault<TKey, TValue>(this IDictionary<TKey, TValue> dict, TKey key)
{
return dict.TryGetValue(key, out var val) ? val : default;
} }
} }

@ -4,40 +4,39 @@ using System.Threading.Tasks;
using FluentValidation; using FluentValidation;
using FluentValidation.Validators; using FluentValidation.Validators;
namespace Common.Extensions namespace Common.Extensions;
public static class FluentValidationExtensions
{ {
public static class FluentValidationExtensions // From: https://github.com/FluentValidation/FluentValidation/issues/1648
public static IRuleBuilderOptions<T, TProperty?> SetNonNullableValidator<T, TProperty>(
this IRuleBuilder<T, TProperty?> ruleBuilder, IValidator<TProperty> validator, params string[] ruleSets)
{ {
// From: https://github.com/FluentValidation/FluentValidation/issues/1648 var adapter = new NullableChildValidatorAdaptor<T, TProperty>(validator, validator.GetType())
public static IRuleBuilderOptions<T, TProperty?> SetNonNullableValidator<T, TProperty>(
this IRuleBuilder<T, TProperty?> ruleBuilder, IValidator<TProperty> validator, params string[] ruleSets)
{ {
var adapter = new NullableChildValidatorAdaptor<T, TProperty>(validator, validator.GetType()) RuleSets = ruleSets
{ };
RuleSets = ruleSets
};
return ruleBuilder.SetAsyncValidator(adapter); return ruleBuilder.SetAsyncValidator(adapter);
} }
private sealed class NullableChildValidatorAdaptor<T, TProperty> : ChildValidatorAdaptor<T, TProperty>, private sealed class NullableChildValidatorAdaptor<T, TProperty> : ChildValidatorAdaptor<T, TProperty>,
IPropertyValidator<T, TProperty?>, IAsyncPropertyValidator<T, TProperty?> IPropertyValidator<T, TProperty?>, IAsyncPropertyValidator<T, TProperty?>
{
public NullableChildValidatorAdaptor(IValidator<TProperty> validator, Type validatorType)
: base(validator, validatorType)
{ {
public NullableChildValidatorAdaptor(IValidator<TProperty> validator, Type validatorType) }
: base(validator, validatorType)
{
}
public override Task<bool> IsValidAsync(ValidationContext<T> context, TProperty? value, public override Task<bool> IsValidAsync(ValidationContext<T> context, TProperty? value,
CancellationToken cancellation) CancellationToken cancellation)
{ {
return base.IsValidAsync(context, value!, cancellation); return base.IsValidAsync(context, value!, cancellation);
} }
public override bool IsValid(ValidationContext<T> context, TProperty? value) public override bool IsValid(ValidationContext<T> context, TProperty? value)
{ {
return base.IsValid(context, value!); return base.IsValid(context, value!);
}
} }
} }
} }

@ -1,18 +1,17 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace Common.Extensions namespace Common.Extensions;
public static class RegexExtensions
{ {
public static class RegexExtensions [SuppressMessage("Design", "CA1021:Avoid out parameters",
Justification =
"The out param has a very specific design purpose. It's to allow regex match expressions " +
"to be executed inside an if condition while also providing match output variable.")]
public static bool Match(this Regex re, string strToCheck, out Match match)
{ {
[SuppressMessage("Design", "CA1021:Avoid out parameters", match = re.Match(strToCheck);
Justification = return match.Success;
"The out param has a very specific design purpose. It's to allow regex match expressions " +
"to be executed inside an if condition while also providing match output variable.")]
public static bool Match(this Regex re, string strToCheck, out Match match)
{
match = re.Match(strToCheck);
return match.Success;
}
} }
} }

@ -3,46 +3,45 @@ using System.Reactive.Disposables;
using System.Reactive.Linq; using System.Reactive.Linq;
using Serilog; using Serilog;
namespace Common.Extensions namespace Common.Extensions;
public static class RxExtensions
{ {
public static class RxExtensions public static IObservable<T> Spy<T>(this IObservable<T> source, ILogger log, string? opName = null)
{ {
public static IObservable<T> Spy<T>(this IObservable<T> source, ILogger log, string? opName = null) opName ??= "IObservable";
log.Debug("{OpName}: Observable obtained on Thread: {ThreadId}",
opName,
Environment.CurrentManagedThreadId);
return Observable.Create<T>(obs =>
{ {
opName ??= "IObservable"; log.Debug("{OpName}: Subscribed to on Thread: {ThreadId}",
log.Debug("{OpName}: Observable obtained on Thread: {ThreadId}",
opName, opName,
Environment.CurrentManagedThreadId); Environment.CurrentManagedThreadId);
return Observable.Create<T>(obs => try
{ {
log.Debug("{OpName}: Subscribed to on Thread: {ThreadId}", var subscription = source
opName, .Do(
Environment.CurrentManagedThreadId); x => log.Debug("{OpName}: OnNext({Result}) on Thread: {ThreadId}", opName, x,
Environment.CurrentManagedThreadId),
try ex => log.Debug("{OpName}: OnError({Result}) on Thread: {ThreadId}", opName, ex.Message,
{ Environment.CurrentManagedThreadId),
var subscription = source () => log.Debug("{OpName}: OnCompleted() on Thread: {ThreadId}", opName,
.Do( Environment.CurrentManagedThreadId))
x => log.Debug("{OpName}: OnNext({Result}) on Thread: {ThreadId}", opName, x, .Subscribe(obs);
Environment.CurrentManagedThreadId), return new CompositeDisposable(
ex => log.Debug("{OpName}: OnError({Result}) on Thread: {ThreadId}", opName, ex.Message, subscription,
Environment.CurrentManagedThreadId), Disposable.Create(() => log.Debug(
() => log.Debug("{OpName}: OnCompleted() on Thread: {ThreadId}", opName, "{OpName}: Cleaned up on Thread: {ThreadId}",
Environment.CurrentManagedThreadId)) opName,
.Subscribe(obs); Environment.CurrentManagedThreadId)));
return new CompositeDisposable( }
subscription, finally
Disposable.Create(() => log.Debug( {
"{OpName}: Cleaned up on Thread: {ThreadId}", log.Debug("{OpName}: Subscription completed", opName);
opName, }
Environment.CurrentManagedThreadId))); });
}
finally
{
log.Debug("{OpName}: Subscription completed", opName);
}
});
}
} }
} }

@ -1,33 +1,32 @@
using System; using System;
using System.Globalization; using System.Globalization;
namespace Common.Extensions namespace Common.Extensions;
public static class StringExtensions
{ {
public static class StringExtensions public static bool ContainsIgnoreCase(this string value, string searchFor)
{ {
public static bool ContainsIgnoreCase(this string value, string searchFor) return value.Contains(searchFor, StringComparison.OrdinalIgnoreCase);
{ }
return value.Contains(searchFor, StringComparison.OrdinalIgnoreCase);
}
public static bool EqualsIgnoreCase(this string value, string? matchThis) public static bool EqualsIgnoreCase(this string value, string? matchThis)
{ {
return value.Equals(matchThis, StringComparison.OrdinalIgnoreCase); return value.Equals(matchThis, StringComparison.OrdinalIgnoreCase);
} }
public static float ToFloat(this string value) public static float ToFloat(this string value)
{ {
return float.Parse(value, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat); return float.Parse(value, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat);
} }
public static decimal ToDecimal(this string value) public static decimal ToDecimal(this string value)
{ {
return decimal.Parse(value, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat); return decimal.Parse(value, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat);
} }
public static string FormatWith(this string value, params object[] args) public static string FormatWith(this string value, params object[] args)
{ {
return string.Format(value, args); return string.Format(value, args);
}
} }
} }

@ -1,26 +1,25 @@
using System; using System;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace Common namespace Common;
public static class JsonNetExtensions
{ {
public static class JsonNetExtensions public static JEnumerable<T> Children<T>(this JToken token, string key)
where T : JToken
{ {
public static JEnumerable<T> Children<T>(this JToken token, string key) return token[key]?.Children<T>() ?? JEnumerable<T>.Empty;
where T : JToken }
{
return token[key]?.Children<T>() ?? JEnumerable<T>.Empty;
}
public static T ValueOrThrow<T>(this JToken token, string key) public static T ValueOrThrow<T>(this JToken token, string key)
where T : class where T : class
{
var value = token.Value<T>(key);
if (value is null)
{ {
var value = token.Value<T>(key); throw new ArgumentNullException(token.Path);
if (value is null)
{
throw new ArgumentNullException(token.Path);
}
return value;
} }
return value;
} }
} }

@ -3,41 +3,40 @@ using System.IO;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
namespace Common namespace Common;
public class ResourceDataReader
{ {
public class ResourceDataReader private readonly Assembly? _assembly;
private readonly string? _namespace;
private readonly string _subdirectory;
public ResourceDataReader(Type typeWithNamespaceToUse, string subdirectory = "")
{ {
private readonly Assembly? _assembly; _subdirectory = subdirectory;
private readonly string? _namespace; _namespace = typeWithNamespaceToUse.Namespace;
private readonly string _subdirectory; _assembly = Assembly.GetAssembly(typeWithNamespaceToUse);
}
public ResourceDataReader(Type typeWithNamespaceToUse, string subdirectory = "") public string ReadData(string filename)
{
var nameBuilder = new StringBuilder();
nameBuilder.Append(_namespace);
if (!string.IsNullOrEmpty(_subdirectory))
{ {
_subdirectory = subdirectory; nameBuilder.Append($".{_subdirectory}");
_namespace = typeWithNamespaceToUse.Namespace;
_assembly = Assembly.GetAssembly(typeWithNamespaceToUse);
} }
public string ReadData(string filename) nameBuilder.Append($".{filename}");
{
var nameBuilder = new StringBuilder();
nameBuilder.Append(_namespace);
if (!string.IsNullOrEmpty(_subdirectory))
{
nameBuilder.Append($".{_subdirectory}");
}
nameBuilder.Append($".{filename}"); var resourceName = nameBuilder.ToString();
using var stream = _assembly?.GetManifestResourceStream(resourceName);
var resourceName = nameBuilder.ToString(); if (stream == null)
using var stream = _assembly?.GetManifestResourceStream(resourceName); {
if (stream == null) throw new ArgumentException($"Embedded resource not found: {resourceName}");
{
throw new ArgumentException($"Embedded resource not found: {resourceName}");
}
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
} }
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
} }
} }

@ -2,16 +2,15 @@ using System;
using System.Collections; using System.Collections;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Common.YamlDotNet namespace Common.YamlDotNet;
[AttributeUsage(AttributeTargets.Property)]
public sealed class CannotBeEmptyAttribute : RequiredAttribute
{ {
[AttributeUsage(AttributeTargets.Property)] public override bool IsValid(object? value)
public sealed class CannotBeEmptyAttribute : RequiredAttribute
{ {
public override bool IsValid(object? value) return base.IsValid(value) &&
{ value is IEnumerable list &&
return base.IsValid(value) && list.GetEnumerator().MoveNext();
value is IEnumerable list &&
list.GetEnumerator().MoveNext();
}
} }
} }

@ -3,43 +3,42 @@ using System.ComponentModel.DataAnnotations;
using YamlDotNet.Core; using YamlDotNet.Core;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
namespace Common.YamlDotNet namespace Common.YamlDotNet;
internal class ValidatingDeserializer : INodeDeserializer
{ {
internal class ValidatingDeserializer : INodeDeserializer private readonly INodeDeserializer _nodeDeserializer;
public ValidatingDeserializer(INodeDeserializer nodeDeserializer)
{ {
private readonly INodeDeserializer _nodeDeserializer; _nodeDeserializer = nodeDeserializer;
}
public ValidatingDeserializer(INodeDeserializer nodeDeserializer) public bool Deserialize(IParser reader, Type expectedType,
Func<IParser, Type, object?> nestedObjectDeserializer, out object? value)
{
if (!_nodeDeserializer.Deserialize(reader, expectedType, nestedObjectDeserializer, out value) ||
value == null)
{ {
_nodeDeserializer = nodeDeserializer; return false;
} }
public bool Deserialize(IParser reader, Type expectedType, var context = new ValidationContext(value, null, null);
Func<IParser, Type, object?> nestedObjectDeserializer, out object? value)
{
if (!_nodeDeserializer.Deserialize(reader, expectedType, nestedObjectDeserializer, out value) ||
value == null)
{
return false;
}
var context = new ValidationContext(value, null, null);
try try
{ {
Validator.ValidateObject(value, context, true); Validator.ValidateObject(value, context, true);
} }
catch (ValidationException e) catch (ValidationException e)
{
if (reader.Current == null)
{ {
if (reader.Current == null) throw;
{
throw;
}
throw new YamlException(reader.Current.Start, reader.Current.End, e.Message);
} }
return true; throw new YamlException(reader.Current.Start, reader.Current.End, e.Message);
} }
return true;
} }
} }

@ -2,29 +2,28 @@
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NodeDeserializers; using YamlDotNet.Serialization.NodeDeserializers;
namespace Common.YamlDotNet namespace Common.YamlDotNet;
public static class YamlDotNetExtensions
{ {
public static class YamlDotNetExtensions public static T? DeserializeType<T>(this IDeserializer deserializer, string data)
where T : class
{ {
public static T? DeserializeType<T>(this IDeserializer deserializer, string data) var extractor = deserializer.Deserialize<RootExtractor<T>>(data);
where T : class return extractor.RootObject;
{ }
var extractor = deserializer.Deserialize<RootExtractor<T>>(data);
return extractor.RootObject;
}
public static DeserializerBuilder WithRequiredPropertyValidation(this DeserializerBuilder builder) public static DeserializerBuilder WithRequiredPropertyValidation(this DeserializerBuilder builder)
{ {
return builder return builder
.WithNodeDeserializer(inner => new ValidatingDeserializer(inner), .WithNodeDeserializer(inner => new ValidatingDeserializer(inner),
s => s.InsteadOf<ObjectNodeDeserializer>()); s => s.InsteadOf<ObjectNodeDeserializer>());
} }
[UsedImplicitly(ImplicitUseTargetFlags.Members)] [UsedImplicitly(ImplicitUseTargetFlags.Members)]
private sealed class RootExtractor<T> private sealed class RootExtractor<T>
where T : class where T : class
{ {
public T? RootObject { get; } public T? RootObject { get; }
}
} }
} }

@ -3,70 +3,69 @@ using YamlDotNet.Core;
using YamlDotNet.Core.Events; using YamlDotNet.Core.Events;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
namespace Common.YamlDotNet namespace Common.YamlDotNet;
// A workaround for nullable enums in YamlDotNet taken from:
// https://github.com/aaubry/YamlDotNet/issues/544#issuecomment-778062351
public class YamlNullableEnumTypeConverter : IYamlTypeConverter
{ {
// A workaround for nullable enums in YamlDotNet taken from: public bool Accepts(Type type)
// https://github.com/aaubry/YamlDotNet/issues/544#issuecomment-778062351
public class YamlNullableEnumTypeConverter : IYamlTypeConverter
{ {
public bool Accepts(Type type) return Nullable.GetUnderlyingType(type)?.IsEnum ?? false;
}
public object? ReadYaml(IParser parser, Type type)
{
type = Nullable.GetUnderlyingType(type) ??
throw new ArgumentException("Expected nullable enum type for ReadYaml");
if (parser.Accept<NodeEvent>(out var @event) && NodeIsNull(@event))
{ {
return Nullable.GetUnderlyingType(type)?.IsEnum ?? false; parser.SkipThisAndNestedEvents();
return null;
} }
public object? ReadYaml(IParser parser, Type type) var scalar = parser.Consume<Scalar>();
try
{ {
type = Nullable.GetUnderlyingType(type) ?? return Enum.Parse(type, scalar.Value, true);
throw new ArgumentException("Expected nullable enum type for ReadYaml");
if (parser.Accept<NodeEvent>(out var @event) && NodeIsNull(@event))
{
parser.SkipThisAndNestedEvents();
return null;
}
var scalar = parser.Consume<Scalar>();
try
{
return Enum.Parse(type, scalar.Value, true);
}
catch (Exception ex)
{
throw new YamlException($"Invalid value: \"{scalar.Value}\" for {type.Name}", ex);
}
} }
catch (Exception ex)
public void WriteYaml(IEmitter emitter, object? value, Type type)
{ {
type = Nullable.GetUnderlyingType(type) ?? throw new YamlException($"Invalid value: \"{scalar.Value}\" for {type.Name}", ex);
throw new ArgumentException("Expected nullable enum type for WriteYaml"); }
}
if (value == null) public void WriteYaml(IEmitter emitter, object? value, Type type)
{ {
return; type = Nullable.GetUnderlyingType(type) ??
} throw new ArgumentException("Expected nullable enum type for WriteYaml");
var toWrite = Enum.GetName(type, value) ?? if (value == null)
throw new InvalidOperationException($"Invalid value {value} for enum: {type}"); {
emitter.Emit(new Scalar(null!, null!, toWrite, ScalarStyle.Any, true, false)); return;
} }
private static bool NodeIsNull(NodeEvent nodeEvent) var toWrite = Enum.GetName(type, value) ??
{ throw new InvalidOperationException($"Invalid value {value} for enum: {type}");
// http://yaml.org/type/null.html emitter.Emit(new Scalar(null!, null!, toWrite, ScalarStyle.Any, true, false));
}
if (nodeEvent.Tag == "tag:yaml.org,2002:null") private static bool NodeIsNull(NodeEvent nodeEvent)
{ {
return true; // http://yaml.org/type/null.html
}
if (nodeEvent is not Scalar {Style: ScalarStyle.Plain} scalar) if (nodeEvent.Tag == "tag:yaml.org,2002:null")
{ {
return false; return true;
} }
var value = scalar.Value; if (nodeEvent is not Scalar {Style: ScalarStyle.Plain} scalar)
return value is "" or "~" or "null" or "Null" or "NULL"; {
return false;
} }
var value = scalar.Value;
return value is "" or "~" or "null" or "Null" or "NULL";
} }
} }

@ -1,17 +1,16 @@
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
namespace TestLibrary.Tests namespace TestLibrary.Tests;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class StreamBuilderTest
{ {
[TestFixture] [Test]
[Parallelizable(ParallelScope.All)] public void FromString_UsingString_ShouldOutputSameString()
public class StreamBuilderTest
{ {
[Test] var stream = StreamBuilder.FromString("test");
public void FromString_UsingString_ShouldOutputSameString() stream.ReadToEnd().Should().Be("test");
{
var stream = StreamBuilder.FromString("test");
stream.ReadToEnd().Should().Be("test");
}
} }
} }

@ -1,17 +1,16 @@
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
namespace TestLibrary.Tests namespace TestLibrary.Tests;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class StringUtilsTest
{ {
[TestFixture] [Test]
[Parallelizable(ParallelScope.All)] public void TrimmedString_Newlines_AreStripped()
public class StringUtilsTest
{ {
[Test] var testStr = "\r\ntest\r\n";
public void TrimmedString_Newlines_AreStripped() StringUtils.TrimmedString(testStr).Should().Be("test");
{
var testStr = "\r\ntest\r\n";
StringUtils.TrimmedString(testStr).Should().Be("test");
}
} }
} }

@ -2,23 +2,22 @@ using FluentAssertions.Equivalency;
using FluentAssertions.Json; using FluentAssertions.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace TestLibrary.FluentAssertions namespace TestLibrary.FluentAssertions;
public class JsonEquivalencyStep : IEquivalencyStep
{ {
public class JsonEquivalencyStep : IEquivalencyStep public EquivalencyResult Handle(Comparands comparands, IEquivalencyValidationContext context,
IEquivalencyValidator nestedValidator)
{ {
public EquivalencyResult Handle(Comparands comparands, IEquivalencyValidationContext context, var canHandle = comparands.Subject?.GetType().IsAssignableTo(typeof(JToken)) ?? false;
IEquivalencyValidator nestedValidator) if (!canHandle)
{ {
var canHandle = comparands.Subject?.GetType().IsAssignableTo(typeof(JToken)) ?? false; return EquivalencyResult.ContinueWithNext;
if (!canHandle) }
{
return EquivalencyResult.ContinueWithNext;
}
((JToken) comparands.Subject!).Should().BeEquivalentTo( ((JToken) comparands.Subject!).Should().BeEquivalentTo(
(JToken) comparands.Expectation, context.Reason.FormattedMessage, context.Reason.Arguments); (JToken) comparands.Expectation, context.Reason.FormattedMessage, context.Reason.Arguments);
return EquivalencyResult.AssertionCompleted; return EquivalencyResult.AssertionCompleted;
}
} }
} }

@ -4,38 +4,37 @@ using System.Linq;
using FluentAssertions.Execution; using FluentAssertions.Execution;
using NSubstitute.Core.Arguments; using NSubstitute.Core.Arguments;
namespace TestLibrary.NSubstitute namespace TestLibrary.NSubstitute;
public static class Verify
{ {
public static class Verify public static T That<T>(Action<T> action)
{ {
public static T That<T>(Action<T> action) return ArgumentMatcher.Enqueue(new AssertionMatcher<T>(action));
}
private class AssertionMatcher<T> : IArgumentMatcher<T>
{
private readonly Action<T> _assertion;
public AssertionMatcher(Action<T> assertion)
{ {
return ArgumentMatcher.Enqueue(new AssertionMatcher<T>(action)); _assertion = assertion;
} }
private class AssertionMatcher<T> : IArgumentMatcher<T> public bool IsSatisfiedBy(T argument)
{ {
private readonly Action<T> _assertion; using var scope = new AssertionScope();
_assertion(argument);
public AssertionMatcher(Action<T> assertion) var failures = scope.Discard().ToList();
if (failures.Count == 0)
{ {
_assertion = assertion; return true;
} }
public bool IsSatisfiedBy(T argument) failures.ForEach(x => Trace.WriteLine(x));
{ return false;
using var scope = new AssertionScope();
_assertion(argument);
var failures = scope.Discard().ToList();
if (failures.Count == 0)
{
return true;
}
failures.ForEach(x => Trace.WriteLine(x));
return false;
}
} }
} }
} }

@ -1,14 +1,13 @@
using System.IO; using System.IO;
using System.Text; using System.Text;
namespace TestLibrary namespace TestLibrary;
public static class StreamBuilder
{ {
public static class StreamBuilder public static StreamReader FromString(string data)
{ {
public static StreamReader FromString(string data) var stream = new MemoryStream(Encoding.UTF8.GetBytes(data));
{ return new StreamReader(stream);
var stream = new MemoryStream(Encoding.UTF8.GetBytes(data));
return new StreamReader(stream);
}
} }
} }

@ -1,7 +1,6 @@
namespace TestLibrary namespace TestLibrary;
public static class StringUtils
{ {
public static class StringUtils public static string TrimmedString(string value) => value.Trim('\r', '\n');
{
public static string TrimmedString(string value) => value.Trim('\r', '\n');
}
} }

@ -8,39 +8,38 @@ using Trash.Command;
// ReSharper disable MethodHasAsyncOverload // ReSharper disable MethodHasAsyncOverload
namespace Trash.Tests.Command namespace Trash.Tests.Command;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CreateConfigCommandTest
{ {
[TestFixture] [Test]
[Parallelizable(ParallelScope.All)] public async Task CreateConfig_DefaultPath_FileIsCreated()
public class CreateConfigCommandTest
{ {
[Test] var logger = Substitute.For<ILogger>();
public async Task CreateConfig_DefaultPath_FileIsCreated() var filesystem = Substitute.For<IFileSystem>();
{ var cmd = new CreateConfigCommand(logger, filesystem);
var logger = Substitute.For<ILogger>();
var filesystem = Substitute.For<IFileSystem>();
var cmd = new CreateConfigCommand(logger, filesystem);
await cmd.ExecuteAsync(Substitute.For<IConsole>()).ConfigureAwait(false); await cmd.ExecuteAsync(Substitute.For<IConsole>()).ConfigureAwait(false);
filesystem.File.Received().Exists(Arg.Is<string>(s => s.EndsWith("trash.yml"))); filesystem.File.Received().Exists(Arg.Is<string>(s => s.EndsWith("trash.yml")));
filesystem.File.Received().WriteAllText(Arg.Is<string>(s => s.EndsWith("trash.yml")), Arg.Any<string>()); filesystem.File.Received().WriteAllText(Arg.Is<string>(s => s.EndsWith("trash.yml")), Arg.Any<string>());
} }
[Test] [Test]
public async Task CreateConfig_SpecifyPath_FileIsCreated() public async Task CreateConfig_SpecifyPath_FileIsCreated()
{
var logger = Substitute.For<ILogger>();
var filesystem = Substitute.For<IFileSystem>();
var cmd = new CreateConfigCommand(logger, filesystem)
{ {
var logger = Substitute.For<ILogger>(); Path = "some/other/path.yml"
var filesystem = Substitute.For<IFileSystem>(); };
var cmd = new CreateConfigCommand(logger, filesystem)
{ await cmd.ExecuteAsync(Substitute.For<IConsole>()).ConfigureAwait(false);
Path = "some/other/path.yml"
}; filesystem.File.Received().Exists(Arg.Is("some/other/path.yml"));
filesystem.File.Received().WriteAllText(Arg.Is("some/other/path.yml"), Arg.Any<string>());
await cmd.ExecuteAsync(Substitute.For<IConsole>()).ConfigureAwait(false);
filesystem.File.Received().Exists(Arg.Is("some/other/path.yml"));
filesystem.File.Received().WriteAllText(Arg.Is("some/other/path.yml"), Arg.Any<string>());
}
} }
} }

@ -5,53 +5,52 @@ using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using Trash.Command.Helpers; using Trash.Command.Helpers;
namespace Trash.Tests.Command.Helpers namespace Trash.Tests.Command.Helpers;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CliTypeActivatorTest
{ {
[TestFixture] private class NonServiceCommandType
[Parallelizable(ParallelScope.All)] {
public class CliTypeActivatorTest }
private class StubCommand : IServiceCommand
{ {
private class NonServiceCommandType public bool Preview => false;
{ public bool Debug => false;
} public ICollection<string>? Config => null;
public string CacheStoragePath => "";
private class StubCommand : IServiceCommand }
{
public bool Preview => false; [Test]
public bool Debug => false; public void Resolve_NonServiceCommandType_NoActiveCommandSet()
public ICollection<string>? Config => null; {
public string CacheStoragePath => ""; var builder = new ContainerBuilder();
} builder.RegisterType<NonServiceCommandType>();
var container = CompositionRoot.Setup(builder);
[Test]
public void Resolve_NonServiceCommandType_NoActiveCommandSet() var createdType = CliTypeActivator.ResolveType(container, typeof(NonServiceCommandType));
{
var builder = new ContainerBuilder(); Action act = () => _ = container.Resolve<IActiveServiceCommandProvider>().ActiveCommand;
builder.RegisterType<NonServiceCommandType>();
var container = CompositionRoot.Setup(builder); createdType.Should().BeOfType<NonServiceCommandType>();
act.Should()
var createdType = CliTypeActivator.ResolveType(container, typeof(NonServiceCommandType)); .Throw<InvalidOperationException>()
.WithMessage("The active command has not yet been determined");
Action act = () => _ = container.Resolve<IActiveServiceCommandProvider>().ActiveCommand; }
createdType.Should().BeOfType<NonServiceCommandType>(); [Test]
act.Should() public void Resolve_ServiceCommandType_ActiveCommandSet()
.Throw<InvalidOperationException>() {
.WithMessage("The active command has not yet been determined"); var builder = new ContainerBuilder();
} builder.RegisterType<StubCommand>();
var container = CompositionRoot.Setup(builder);
[Test]
public void Resolve_ServiceCommandType_ActiveCommandSet() var createdType = CliTypeActivator.ResolveType(container, typeof(StubCommand));
{ var activeCommand = container.Resolve<IActiveServiceCommandProvider>().ActiveCommand;
var builder = new ContainerBuilder();
builder.RegisterType<StubCommand>(); activeCommand.Should().BeSameAs(createdType);
var container = CompositionRoot.Setup(builder); activeCommand.Should().BeOfType<StubCommand>();
var createdType = CliTypeActivator.ResolveType(container, typeof(StubCommand));
var activeCommand = container.Resolve<IActiveServiceCommandProvider>().ActiveCommand;
activeCommand.Should().BeSameAs(createdType);
activeCommand.Should().BeOfType<StubCommand>();
}
} }
} }

@ -5,37 +5,36 @@ using Autofac.Core;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
namespace Trash.Tests namespace Trash.Tests;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CompositionRootTest
{ {
[TestFixture] private sealed class ConcreteTypeEnumerator : IEnumerable
[Parallelizable(ParallelScope.All)]
public class CompositionRootTest
{ {
private sealed class ConcreteTypeEnumerator : IEnumerable private readonly IContainer _container;
{
private readonly IContainer _container;
public ConcreteTypeEnumerator() public ConcreteTypeEnumerator()
{ {
_container = CompositionRoot.Setup(); _container = CompositionRoot.Setup();
}
public IEnumerator GetEnumerator()
{
return _container.ComponentRegistry.Registrations
.SelectMany(x => x.Services)
.OfType<TypedService>()
.GetEnumerator();
}
} }
[TestCaseSource(typeof(ConcreteTypeEnumerator))] public IEnumerator GetEnumerator()
public void Resolve_ICommandConcreteClasses(Service service)
{ {
using var container = CompositionRoot.Setup(); return _container.ComponentRegistry.Registrations
container.Invoking(c => c.ResolveService(service)) .SelectMany(x => x.Services)
.Should().NotThrow() .OfType<TypedService>()
.And.NotBeNull(); .GetEnumerator();
} }
} }
[TestCaseSource(typeof(ConcreteTypeEnumerator))]
public void Resolve_ICommandConcreteClasses(Service service)
{
using var container = CompositionRoot.Setup();
container.Invoking(c => c.ResolveService(service))
.Should().NotThrow()
.And.NotBeNull();
}
} }

@ -19,157 +19,156 @@ using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile; using TrashLib.Sonarr.ReleaseProfile;
using YamlDotNet.Serialization.ObjectFactories; using YamlDotNet.Serialization.ObjectFactories;
namespace Trash.Tests.Config namespace Trash.Tests.Config;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigurationLoaderTest
{ {
[TestFixture] private static TextReader GetResourceData(string file)
[Parallelizable(ParallelScope.All)]
public class ConfigurationLoaderTest
{ {
private static TextReader GetResourceData(string file) var testData = new ResourceDataReader(typeof(ConfigurationLoaderTest), "Data");
{ return new StringReader(testData.ReadData(file));
var testData = new ResourceDataReader(typeof(ConfigurationLoaderTest), "Data"); }
return new StringReader(testData.ReadData(file));
}
[SuppressMessage("Microsoft.Design", "CA1034", [SuppressMessage("Microsoft.Design", "CA1034",
Justification = "YamlDotNet requires this type to be public so it may access it")] Justification = "YamlDotNet requires this type to be public so it may access it")]
public class TestConfig : IServiceConfiguration public class TestConfig : IServiceConfiguration
{
public string BaseUrl => "";
public string ApiKey => "";
}
[Test]
public void Load_many_iterations_of_config()
{
static StreamReader MockYaml(params object[] args)
{ {
public string BaseUrl => ""; var str = new StringBuilder("sonarr:");
public string ApiKey => ""; const string templateYaml = "\n - base_url: {0}\n api_key: abc";
str.Append(args.Aggregate("", (current, p) => current + templateYaml.FormatWith(p)));
return StreamBuilder.FromString(str.ToString());
} }
[Test] var fs = Substitute.For<IFileSystem>();
public void Load_many_iterations_of_config() fs.File.OpenText(Arg.Any<string>())
{ .Returns(MockYaml(1, 2), MockYaml(3));
static StreamReader MockYaml(params object[] args)
{ var provider = Substitute.For<IConfigurationProvider>();
var str = new StringBuilder("sonarr:"); // var objectFactory = Substitute.For<IObjectFactory>();
const string templateYaml = "\n - base_url: {0}\n api_key: abc"; // objectFactory.Create(Arg.Any<Type>())
str.Append(args.Aggregate("", (current, p) => current + templateYaml.FormatWith(p))); // .Returns(t => Substitute.For(new[] {(Type)t[0]}, Array.Empty<object>()));
return StreamBuilder.FromString(str.ToString());
} var actualActiveConfigs = new List<SonarrConfiguration>();
var fs = Substitute.For<IFileSystem>();
fs.File.OpenText(Arg.Any<string>())
.Returns(MockYaml(1, 2), MockYaml(3));
var provider = Substitute.For<IConfigurationProvider>();
// var objectFactory = Substitute.For<IObjectFactory>();
// objectFactory.Create(Arg.Any<Type>())
// .Returns(t => Substitute.For(new[] {(Type)t[0]}, Array.Empty<object>()));
var actualActiveConfigs = new List<SonarrConfiguration>();
#pragma warning disable NS1004 #pragma warning disable NS1004
provider.ActiveConfiguration = Arg.Do<SonarrConfiguration>(a => actualActiveConfigs.Add(a)); provider.ActiveConfiguration = Arg.Do<SonarrConfiguration>(a => actualActiveConfigs.Add(a));
#pragma warning restore NS1004 #pragma warning restore NS1004
var validator = Substitute.For<IValidator<SonarrConfiguration>>(); var validator = Substitute.For<IValidator<SonarrConfiguration>>();
var loader = var loader =
new ConfigurationLoader<SonarrConfiguration>(provider, fs, new DefaultObjectFactory(), validator); new ConfigurationLoader<SonarrConfiguration>(provider, fs, new DefaultObjectFactory(), validator);
var fakeFiles = new List<string> var fakeFiles = new List<string>
{ {
"config1.yml", "config1.yml",
"config2.yml" "config2.yml"
}; };
var expected = new List<SonarrConfiguration> var expected = new List<SonarrConfiguration>
{ {
new() {ApiKey = "abc", BaseUrl = "1"}, new() {ApiKey = "abc", BaseUrl = "1"},
new() {ApiKey = "abc", BaseUrl = "2"}, new() {ApiKey = "abc", BaseUrl = "2"},
new() {ApiKey = "abc", BaseUrl = "3"} new() {ApiKey = "abc", BaseUrl = "3"}
}; };
var actual = loader.LoadMany(fakeFiles, "sonarr").ToList(); var actual = loader.LoadMany(fakeFiles, "sonarr").ToList();
actual.Should().BeEquivalentTo(expected); actual.Should().BeEquivalentTo(expected);
actualActiveConfigs.Should().BeEquivalentTo(expected, op => op.WithoutStrictOrdering()); actualActiveConfigs.Should().BeEquivalentTo(expected, op => op.WithoutStrictOrdering());
} }
[Test] [Test]
public void Parse_using_stream() public void Parse_using_stream()
{ {
var validator = Substitute.For<IValidator<SonarrConfiguration>>(); var validator = Substitute.For<IValidator<SonarrConfiguration>>();
var configLoader = new ConfigurationLoader<SonarrConfiguration>( var configLoader = new ConfigurationLoader<SonarrConfiguration>(
Substitute.For<IConfigurationProvider>(), Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(), Substitute.For<IFileSystem>(),
new DefaultObjectFactory(), new DefaultObjectFactory(),
validator); validator);
var configs = configLoader.LoadFromStream(GetResourceData("Load_UsingStream_CorrectParsing.yml"), "sonarr"); var configs = configLoader.LoadFromStream(GetResourceData("Load_UsingStream_CorrectParsing.yml"), "sonarr");
configs.Should() configs.Should()
.BeEquivalentTo(new List<SonarrConfiguration> .BeEquivalentTo(new List<SonarrConfiguration>
{
new()
{ {
new() ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = "http://localhost:8989",
ReleaseProfiles = new List<ReleaseProfileConfig>
{ {
ApiKey = "95283e6b156c42f3af8a9b16173f876b", new()
BaseUrl = "http://localhost:8989",
ReleaseProfiles = new List<ReleaseProfileConfig>
{ {
new() Type = ReleaseProfileType.Anime,
{ StrictNegativeScores = true,
Type = ReleaseProfileType.Anime, Tags = new List<string> {"anime"}
StrictNegativeScores = true, },
Tags = new List<string> {"anime"} new()
}, {
new() Type = ReleaseProfileType.Series,
StrictNegativeScores = false,
Tags = new List<string>
{ {
Type = ReleaseProfileType.Series, "tv",
StrictNegativeScores = false, "series"
Tags = new List<string>
{
"tv",
"series"
}
} }
} }
} }
}); }
} });
}
[Test] [Test]
public void Throw_when_validation_fails() public void Throw_when_validation_fails()
{
var validator = Substitute.For<IValidator<TestConfig>>();
var configLoader = new ConfigurationLoader<TestConfig>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(),
new DefaultObjectFactory(),
validator);
// force the validator to return a validation error
validator.Validate(Arg.Any<TestConfig>()).Returns(new ValidationResult
{ {
var validator = Substitute.For<IValidator<TestConfig>>(); Errors = {new ValidationFailure("PropertyName", "Test Validation Failure")}
var configLoader = new ConfigurationLoader<TestConfig>( });
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(),
new DefaultObjectFactory(),
validator);
// force the validator to return a validation error
validator.Validate(Arg.Any<TestConfig>()).Returns(new ValidationResult
{
Errors = {new ValidationFailure("PropertyName", "Test Validation Failure")}
});
var testYml = @" var testYml = @"
fubar: fubar:
- api_key: abc - api_key: abc
"; ";
Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "fubar"); Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "fubar");
act.Should().Throw<ConfigurationException>(); act.Should().Throw<ConfigurationException>();
} }
[Test] [Test]
public void Validation_success_does_not_throw() public void Validation_success_does_not_throw()
{ {
var validator = Substitute.For<IValidator<TestConfig>>(); var validator = Substitute.For<IValidator<TestConfig>>();
var configLoader = new ConfigurationLoader<TestConfig>( var configLoader = new ConfigurationLoader<TestConfig>(
Substitute.For<IConfigurationProvider>(), Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(), Substitute.For<IFileSystem>(),
new DefaultObjectFactory(), new DefaultObjectFactory(),
validator); validator);
var testYml = @" var testYml = @"
fubar: fubar:
- api_key: abc - api_key: abc
"; ";
Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "fubar"); Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "fubar");
act.Should().NotThrow(); act.Should().NotThrow();
}
} }
} }

@ -2,40 +2,39 @@ using System.IO.Abstractions;
using NSubstitute; using NSubstitute;
using NUnit.Framework; using NUnit.Framework;
namespace Trash.Tests namespace Trash.Tests;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class LogJanitorTest
{ {
[TestFixture] [Test]
[Parallelizable(ParallelScope.All)] public void Keep_correct_number_of_newest_log_files()
public class LogJanitorTest
{ {
[Test] var fs = Substitute.For<IFileSystem>();
public void Keep_correct_number_of_newest_log_files() var janitor = new LogJanitor(fs);
{
var fs = Substitute.For<IFileSystem>();
var janitor = new LogJanitor(fs);
var testFileInfoList = new[] var testFileInfoList = new[]
{ {
Substitute.For<IFileInfo>(), Substitute.For<IFileInfo>(),
Substitute.For<IFileInfo>(), Substitute.For<IFileInfo>(),
Substitute.For<IFileInfo>(), Substitute.For<IFileInfo>(),
Substitute.For<IFileInfo>() Substitute.For<IFileInfo>()
}; };
testFileInfoList[0].Name.Returns("trash_2021-05-15_19-00-00"); testFileInfoList[0].Name.Returns("trash_2021-05-15_19-00-00");
testFileInfoList[1].Name.Returns("trash_2021-05-15_20-00-00"); testFileInfoList[1].Name.Returns("trash_2021-05-15_20-00-00");
testFileInfoList[2].Name.Returns("trash_2021-05-15_21-00-00"); testFileInfoList[2].Name.Returns("trash_2021-05-15_21-00-00");
testFileInfoList[3].Name.Returns("trash_2021-05-15_22-00-00"); testFileInfoList[3].Name.Returns("trash_2021-05-15_22-00-00");
fs.DirectoryInfo.FromDirectoryName(Arg.Any<string>()).GetFiles() fs.DirectoryInfo.FromDirectoryName(Arg.Any<string>()).GetFiles()
.Returns(testFileInfoList); .Returns(testFileInfoList);
janitor.DeleteOldestLogFiles(2); janitor.DeleteOldestLogFiles(2);
testFileInfoList[0].Received().Delete(); testFileInfoList[0].Received().Delete();
testFileInfoList[1].Received().Delete(); testFileInfoList[1].Received().Delete();
testFileInfoList[2].DidNotReceive().Delete(); testFileInfoList[2].DidNotReceive().Delete();
testFileInfoList[3].DidNotReceive().Delete(); testFileInfoList[3].DidNotReceive().Delete();
}
} }
} }

@ -1,17 +1,16 @@
using System; using System;
using System.IO; using System.IO;
namespace Trash namespace Trash;
internal static class AppPaths
{ {
internal static class AppPaths public static string AppDataPath { get; } =
{ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "trash-updater");
public static string AppDataPath { get; } =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "trash-updater");
public static string DefaultConfigPath { get; } = Path.Combine(AppContext.BaseDirectory, "trash.yml"); public static string DefaultConfigPath { get; } = Path.Combine(AppContext.BaseDirectory, "trash.yml");
public static string LogDirectory { get; } = Path.Combine(AppDataPath, "logs"); public static string LogDirectory { get; } = Path.Combine(AppDataPath, "logs");
public static string RepoDirectory { get; } = Path.Combine(AppDataPath, "repo"); public static string RepoDirectory { get; } = Path.Combine(AppDataPath, "repo");
}
} }

@ -8,42 +8,41 @@ using Common;
using JetBrains.Annotations; using JetBrains.Annotations;
using Serilog; using Serilog;
namespace Trash.Command namespace Trash.Command;
[Command("create-config", Description = "Create a starter YAML configuration file")]
[UsedImplicitly]
public class CreateConfigCommand : ICommand
{ {
[Command("create-config", Description = "Create a starter YAML configuration file")] private readonly IFileSystem _fileSystem;
[UsedImplicitly]
public class CreateConfigCommand : ICommand public CreateConfigCommand(ILogger logger, IFileSystem fileSystem)
{ {
private readonly IFileSystem _fileSystem; Log = logger;
_fileSystem = fileSystem;
}
public CreateConfigCommand(ILogger logger, IFileSystem fileSystem) private ILogger Log { get; }
{
Log = logger;
_fileSystem = fileSystem;
}
private ILogger Log { get; } [CommandOption("path", 'p', Description =
"Path where the new YAML file should be created. Must include the filename (e.g. path/to/config.yml). " +
"File must not already exist. If not specified, uses the default path of `trash.yml` right next to the " +
"executable.")]
public string Path { get; [UsedImplicitly] set; } = AppPaths.DefaultConfigPath;
[CommandOption("path", 'p', Description = public ValueTask ExecuteAsync(IConsole console)
"Path where the new YAML file should be created. Must include the filename (e.g. path/to/config.yml). " + {
"File must not already exist. If not specified, uses the default path of `trash.yml` right next to the " + var reader = new ResourceDataReader(typeof(Program));
"executable.")] var ymlData = reader.ReadData("trash-config-template.yml");
public string Path { get; [UsedImplicitly] set; } = AppPaths.DefaultConfigPath;
public ValueTask ExecuteAsync(IConsole console) if (_fileSystem.File.Exists(Path))
{ {
var reader = new ResourceDataReader(typeof(Program)); throw new CommandException($"The file {Path} already exists. Please choose another path or " +
var ymlData = reader.ReadData("trash-config-template.yml"); "delete/move the existing file and run this command again.");
if (_fileSystem.File.Exists(Path))
{
throw new CommandException($"The file {Path} already exists. Please choose another path or " +
"delete/move the existing file and run this command again.");
}
_fileSystem.File.WriteAllText(Path, ymlData);
Log.Information("Created configuration at: {Path}", Path);
return default;
} }
_fileSystem.File.WriteAllText(Path, ymlData);
Log.Information("Created configuration at: {Path}", Path);
return default;
} }
} }

@ -1,16 +1,15 @@
using System; using System;
namespace Trash.Command.Helpers namespace Trash.Command.Helpers;
public class ActiveServiceCommandProvider : IActiveServiceCommandProvider
{ {
public class ActiveServiceCommandProvider : IActiveServiceCommandProvider private IServiceCommand? _activeCommand;
{
private IServiceCommand? _activeCommand;
public IServiceCommand ActiveCommand public IServiceCommand ActiveCommand
{ {
get => _activeCommand ?? get => _activeCommand ??
throw new InvalidOperationException("The active command has not yet been determined"); throw new InvalidOperationException("The active command has not yet been determined");
set => _activeCommand = value; set => _activeCommand = value;
}
} }
} }

@ -1,16 +1,15 @@
using TrashLib.Cache; using TrashLib.Cache;
namespace Trash.Command.Helpers namespace Trash.Command.Helpers;
{
public class CacheStoragePath : ICacheStoragePath
{
private readonly IActiveServiceCommandProvider _serviceCommandProvider;
public CacheStoragePath(IActiveServiceCommandProvider serviceCommandProvider) public class CacheStoragePath : ICacheStoragePath
{ {
_serviceCommandProvider = serviceCommandProvider; private readonly IActiveServiceCommandProvider _serviceCommandProvider;
}
public string Path => _serviceCommandProvider.ActiveCommand.CacheStoragePath; public CacheStoragePath(IActiveServiceCommandProvider serviceCommandProvider)
{
_serviceCommandProvider = serviceCommandProvider;
} }
public string Path => _serviceCommandProvider.ActiveCommand.CacheStoragePath;
} }

@ -1,20 +1,19 @@
using System; using System;
using Autofac; using Autofac;
namespace Trash.Command.Helpers namespace Trash.Command.Helpers;
internal static class CliTypeActivator
{ {
internal static class CliTypeActivator public static object ResolveType(IContainer container, Type typeToResolve)
{ {
public static object ResolveType(IContainer container, Type typeToResolve) var instance = container.Resolve(typeToResolve);
if (instance.GetType().IsAssignableTo<IServiceCommand>())
{ {
var instance = container.Resolve(typeToResolve); var activeServiceProvider = container.Resolve<IActiveServiceCommandProvider>();
if (instance.GetType().IsAssignableTo<IServiceCommand>()) activeServiceProvider.ActiveCommand = (IServiceCommand) instance;
{
var activeServiceProvider = container.Resolve<IActiveServiceCommandProvider>();
activeServiceProvider.ActiveCommand = (IServiceCommand) instance;
}
return instance;
} }
return instance;
} }
} }

@ -1,8 +1,7 @@
namespace Trash.Command.Helpers namespace Trash.Command.Helpers;
public enum ExitCode
{ {
public enum ExitCode Success = 0,
{ Failure = 1
Success = 0,
Failure = 1
}
} }

@ -1,7 +1,6 @@
namespace Trash.Command.Helpers namespace Trash.Command.Helpers;
public interface IActiveServiceCommandProvider
{ {
public interface IActiveServiceCommandProvider IServiceCommand ActiveCommand { get; set; }
{
IServiceCommand ActiveCommand { get; set; }
}
} }

@ -1,12 +1,11 @@
using System.Collections.Generic; using System.Collections.Generic;
namespace Trash.Command.Helpers namespace Trash.Command.Helpers;
public interface IServiceCommand
{ {
public interface IServiceCommand bool Preview { get; }
{ bool Debug { get; }
bool Preview { get; } ICollection<string>? Config { get; }
bool Debug { get; } string CacheStoragePath { get; }
ICollection<string>? Config { get; }
string CacheStoragePath { get; }
}
} }

@ -15,109 +15,108 @@ using Serilog.Events;
using TrashLib.Extensions; using TrashLib.Extensions;
using YamlDotNet.Core; using YamlDotNet.Core;
namespace Trash.Command.Helpers namespace Trash.Command.Helpers;
public abstract class ServiceCommand : ICommand, IServiceCommand
{ {
public abstract class ServiceCommand : ICommand, IServiceCommand private readonly ILogger _log;
private readonly LoggingLevelSwitch _loggingLevelSwitch;
private readonly ILogJanitor _logJanitor;
protected ServiceCommand(
ILogger log,
LoggingLevelSwitch loggingLevelSwitch,
ILogJanitor logJanitor)
{ {
private readonly ILogger _log; _loggingLevelSwitch = loggingLevelSwitch;
private readonly LoggingLevelSwitch _loggingLevelSwitch; _logJanitor = logJanitor;
private readonly ILogJanitor _logJanitor; _log = log;
}
protected ServiceCommand(
ILogger log, public async ValueTask ExecuteAsync(IConsole console)
LoggingLevelSwitch loggingLevelSwitch, {
ILogJanitor logJanitor) SetupLogging();
SetupHttp();
try
{ {
_loggingLevelSwitch = loggingLevelSwitch; await Process();
_logJanitor = logJanitor;
_log = log;
} }
catch (YamlException e)
public async ValueTask ExecuteAsync(IConsole console)
{ {
SetupLogging(); var inner = e.InnerException;
SetupHttp(); if (inner == null)
try
{
await Process();
}
catch (YamlException e)
{ {
var inner = e.InnerException; throw;
if (inner == null)
{
throw;
}
_log.Error("Found Unrecognized YAML Property: {ErrorMsg}", inner.Message);
_log.Error("Please remove the property quoted in the above message from your YAML file");
throw new CommandException("Exiting due to invalid configuration");
}
catch (Exception e) when (e is not CommandException)
{
_log.Error(e, "Unrecoverable Exception");
ExitDueToFailure();
}
finally
{
CleanupOldLogFiles();
} }
_log.Error("Found Unrecognized YAML Property: {ErrorMsg}", inner.Message);
_log.Error("Please remove the property quoted in the above message from your YAML file");
throw new CommandException("Exiting due to invalid configuration");
}
catch (Exception e) when (e is not CommandException)
{
_log.Error(e, "Unrecoverable Exception");
ExitDueToFailure();
} }
finally
{
CleanupOldLogFiles();
}
}
[CommandOption("preview", 'p', Description = [CommandOption("preview", 'p', Description =
"Only display the processed markdown results without making any API calls.")] "Only display the processed markdown results without making any API calls.")]
public bool Preview { get; [UsedImplicitly] set; } = false; public bool Preview { get; [UsedImplicitly] set; } = false;
[CommandOption("debug", 'd', Description = [CommandOption("debug", 'd', Description =
"Display additional logs useful for development/debug purposes.")] "Display additional logs useful for development/debug purposes.")]
public bool Debug { get; [UsedImplicitly] set; } = false; public bool Debug { get; [UsedImplicitly] set; } = false;
[CommandOption("config", 'c', Description = [CommandOption("config", 'c', Description =
"One or more YAML config files to use. All configs will be used and settings are additive. " + "One or more YAML config files to use. All configs will be used and settings are additive. " +
"If not specified, the script will look for `trash.yml` in the same directory as the executable.")] "If not specified, the script will look for `trash.yml` in the same directory as the executable.")]
public ICollection<string> Config { get; [UsedImplicitly] set; } = public ICollection<string> Config { get; [UsedImplicitly] set; } =
new List<string> {AppPaths.DefaultConfigPath}; new List<string> {AppPaths.DefaultConfigPath};
public abstract string CacheStoragePath { get; } public abstract string CacheStoragePath { get; }
private void CleanupOldLogFiles() private void CleanupOldLogFiles()
{ {
_logJanitor.DeleteOldestLogFiles(20); _logJanitor.DeleteOldestLogFiles(20);
} }
private void SetupLogging() private void SetupLogging()
{ {
_loggingLevelSwitch.MinimumLevel = _loggingLevelSwitch.MinimumLevel =
Debug ? LogEventLevel.Debug : LogEventLevel.Information; Debug ? LogEventLevel.Debug : LogEventLevel.Information;
} }
private void SetupHttp() private void SetupHttp()
{
FlurlHttp.Configure(settings =>
{ {
FlurlHttp.Configure(settings => var jsonSettings = new JsonSerializerSettings
{ {
var jsonSettings = new JsonSerializerSettings // This is important. If any DTOs are missing members, say, if Radarr or Sonarr adds one in a future
{ // version, this needs to fail to indicate that a software change is required. Otherwise, we lose
// This is important. If any DTOs are missing members, say, if Radarr or Sonarr adds one in a future // state between when we request settings, and re-apply them again with a few properties modified.
// version, this needs to fail to indicate that a software change is required. Otherwise, we lose MissingMemberHandling = MissingMemberHandling.Error,
// state between when we request settings, and re-apply them again with a few properties modified.
MissingMemberHandling = MissingMemberHandling.Error, // This makes sure that null properties, such as maxSize and preferredSize in Radarr
// Quality Definitions, do not get written out to JSON request bodies.
// This makes sure that null properties, such as maxSize and preferredSize in Radarr NullValueHandling = NullValueHandling.Ignore
// Quality Definitions, do not get written out to JSON request bodies. };
NullValueHandling = NullValueHandling.Ignore
}; settings.JsonSerializer = new NewtonsoftJsonSerializer(jsonSettings);
FlurlLogging.SetupLogging(settings, _log);
settings.JsonSerializer = new NewtonsoftJsonSerializer(jsonSettings); });
FlurlLogging.SetupLogging(settings, _log); }
});
}
public abstract Task Process(); public abstract Task Process();
protected static void ExitDueToFailure() protected static void ExitDueToFailure()
{ {
throw new CommandException("Exiting due to previous exception"); throw new CommandException("Exiting due to previous exception");
}
} }
} }

@ -12,57 +12,56 @@ using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat; using TrashLib.Radarr.CustomFormat;
using TrashLib.Radarr.QualityDefinition; using TrashLib.Radarr.QualityDefinition;
namespace Trash.Command namespace Trash.Command;
[Command("radarr", Description = "Perform operations on a Radarr instance")]
[UsedImplicitly]
public class RadarrCommand : ServiceCommand
{ {
[Command("radarr", Description = "Perform operations on a Radarr instance")] private readonly IConfigurationLoader<RadarrConfiguration> _configLoader;
[UsedImplicitly] private readonly Func<ICustomFormatUpdater> _customFormatUpdaterFactory;
public class RadarrCommand : ServiceCommand private readonly ILogger _log;
{ private readonly Func<IRadarrQualityDefinitionUpdater> _qualityUpdaterFactory;
private readonly IConfigurationLoader<RadarrConfiguration> _configLoader;
private readonly Func<ICustomFormatUpdater> _customFormatUpdaterFactory;
private readonly ILogger _log;
private readonly Func<IRadarrQualityDefinitionUpdater> _qualityUpdaterFactory;
public RadarrCommand( public RadarrCommand(
ILogger log, ILogger log,
LoggingLevelSwitch loggingLevelSwitch, LoggingLevelSwitch loggingLevelSwitch,
ILogJanitor logJanitor, ILogJanitor logJanitor,
IConfigurationLoader<RadarrConfiguration> configLoader, IConfigurationLoader<RadarrConfiguration> configLoader,
Func<IRadarrQualityDefinitionUpdater> qualityUpdaterFactory, Func<IRadarrQualityDefinitionUpdater> qualityUpdaterFactory,
Func<ICustomFormatUpdater> customFormatUpdaterFactory) Func<ICustomFormatUpdater> customFormatUpdaterFactory)
: base(log, loggingLevelSwitch, logJanitor) : base(log, loggingLevelSwitch, logJanitor)
{ {
_log = log; _log = log;
_configLoader = configLoader; _configLoader = configLoader;
_qualityUpdaterFactory = qualityUpdaterFactory; _qualityUpdaterFactory = qualityUpdaterFactory;
_customFormatUpdaterFactory = customFormatUpdaterFactory; _customFormatUpdaterFactory = customFormatUpdaterFactory;
} }
public override string CacheStoragePath { get; } = public override string CacheStoragePath { get; } =
Path.Combine(AppPaths.AppDataPath, "cache", "radarr"); Path.Combine(AppPaths.AppDataPath, "cache", "radarr");
public override async Task Process() public override async Task Process()
{
try
{ {
try foreach (var config in _configLoader.LoadMany(Config, "radarr"))
{ {
foreach (var config in _configLoader.LoadMany(Config, "radarr")) if (config.QualityDefinition != null)
{ {
if (config.QualityDefinition != null) await _qualityUpdaterFactory().Process(Preview, config);
{ }
await _qualityUpdaterFactory().Process(Preview, config);
}
if (config.CustomFormats.Count > 0) if (config.CustomFormats.Count > 0)
{ {
await _customFormatUpdaterFactory().Process(Preview, config); await _customFormatUpdaterFactory().Process(Preview, config);
}
} }
} }
catch (FlurlHttpException e) }
{ catch (FlurlHttpException e)
_log.Error(e, "HTTP error while communicating with Radarr"); {
ExitDueToFailure(); _log.Error(e, "HTTP error while communicating with Radarr");
} ExitDueToFailure();
} }
} }
} }

@ -12,57 +12,56 @@ using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.QualityDefinition; using TrashLib.Sonarr.QualityDefinition;
using TrashLib.Sonarr.ReleaseProfile; using TrashLib.Sonarr.ReleaseProfile;
namespace Trash.Command namespace Trash.Command;
[Command("sonarr", Description = "Perform operations on a Sonarr instance")]
[UsedImplicitly]
public class SonarrCommand : ServiceCommand
{ {
[Command("sonarr", Description = "Perform operations on a Sonarr instance")] private readonly IConfigurationLoader<SonarrConfiguration> _configLoader;
[UsedImplicitly] private readonly ILogger _log;
public class SonarrCommand : ServiceCommand private readonly Func<IReleaseProfileUpdater> _profileUpdaterFactory;
{ private readonly Func<ISonarrQualityDefinitionUpdater> _qualityUpdaterFactory;
private readonly IConfigurationLoader<SonarrConfiguration> _configLoader;
private readonly ILogger _log;
private readonly Func<IReleaseProfileUpdater> _profileUpdaterFactory;
private readonly Func<ISonarrQualityDefinitionUpdater> _qualityUpdaterFactory;
public SonarrCommand( public SonarrCommand(
ILogger log, ILogger log,
LoggingLevelSwitch loggingLevelSwitch, LoggingLevelSwitch loggingLevelSwitch,
ILogJanitor logJanitor, ILogJanitor logJanitor,
IConfigurationLoader<SonarrConfiguration> configLoader, IConfigurationLoader<SonarrConfiguration> configLoader,
Func<IReleaseProfileUpdater> profileUpdaterFactory, Func<IReleaseProfileUpdater> profileUpdaterFactory,
Func<ISonarrQualityDefinitionUpdater> qualityUpdaterFactory) Func<ISonarrQualityDefinitionUpdater> qualityUpdaterFactory)
: base(log, loggingLevelSwitch, logJanitor) : base(log, loggingLevelSwitch, logJanitor)
{ {
_log = log; _log = log;
_configLoader = configLoader; _configLoader = configLoader;
_profileUpdaterFactory = profileUpdaterFactory; _profileUpdaterFactory = profileUpdaterFactory;
_qualityUpdaterFactory = qualityUpdaterFactory; _qualityUpdaterFactory = qualityUpdaterFactory;
} }
public override string CacheStoragePath { get; } = public override string CacheStoragePath { get; } =
Path.Combine(AppPaths.AppDataPath, "cache", "sonarr"); Path.Combine(AppPaths.AppDataPath, "cache", "sonarr");
public override async Task Process() public override async Task Process()
{
try
{ {
try foreach (var config in _configLoader.LoadMany(Config, "sonarr"))
{ {
foreach (var config in _configLoader.LoadMany(Config, "sonarr")) if (config.ReleaseProfiles.Count > 0)
{ {
if (config.ReleaseProfiles.Count > 0) await _profileUpdaterFactory().Process(Preview, config);
{ }
await _profileUpdaterFactory().Process(Preview, config);
}
if (config.QualityDefinition.HasValue) if (config.QualityDefinition.HasValue)
{ {
await _qualityUpdaterFactory().Process(Preview, config); await _qualityUpdaterFactory().Process(Preview, config);
}
} }
} }
catch (FlurlHttpException e) }
{ catch (FlurlHttpException e)
_log.Error(e, "HTTP error while communicating with Sonarr"); {
ExitDueToFailure(); _log.Error(e, "HTTP error while communicating with Sonarr");
} ExitDueToFailure();
} }
} }
} }

@ -17,83 +17,82 @@ using TrashLib.Sonarr;
using TrashLib.Startup; using TrashLib.Startup;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
namespace Trash namespace Trash;
public static class CompositionRoot
{ {
public static class CompositionRoot private static void SetupLogging(ContainerBuilder builder)
{
builder.RegisterType<LogJanitor>().As<ILogJanitor>();
builder.RegisterType<LoggingLevelSwitch>().SingleInstance();
builder.Register(c =>
{
var logPath = Path.Combine(AppPaths.LogDirectory,
$"trash_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.log");
const string consoleTemplate = "[{Level:u3}] {Message:lj}{NewLine}{Exception}";
return new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console(outputTemplate: consoleTemplate, levelSwitch: c.Resolve<LoggingLevelSwitch>())
.WriteTo.File(logPath)
.CreateLogger();
})
.As<ILogger>()
.SingleInstance();
}
private static void ConfigurationRegistrations(ContainerBuilder builder)
{
builder.RegisterModule<ConfigAutofacModule>();
builder.RegisterType<ObjectFactory>().As<IObjectFactory>();
builder.RegisterType<ResourcePaths>().As<IResourcePaths>();
builder.RegisterGeneric(typeof(ConfigurationLoader<>))
.WithProperty(new AutowiringParameter())
.As(typeof(IConfigurationLoader<>));
// note: Do not allow consumers to resolve IServiceConfiguration directly; if this gets cached
// they end up using the wrong configuration when multiple instances are used.
// builder.Register(c => c.Resolve<IConfigurationProvider>().ActiveConfiguration)
// .As<IServiceConfiguration>();
}
private static void CommandRegistrations(ContainerBuilder builder)
{
// Register all types deriving from CliFx's ICommand. These are all of our supported subcommands.
builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
.Where(t => t.IsAssignableTo(typeof(ICommand)));
// Used to access the chosen command class. This is assigned from CliTypeActivator
builder.RegisterType<ActiveServiceCommandProvider>()
.As<IActiveServiceCommandProvider>()
.SingleInstance();
}
public static IContainer Setup()
{
return Setup(new ContainerBuilder());
}
public static IContainer Setup(ContainerBuilder builder)
{ {
private static void SetupLogging(ContainerBuilder builder) builder.RegisterType<FileSystem>().As<IFileSystem>();
{
builder.RegisterType<LogJanitor>().As<ILogJanitor>(); builder.RegisterModule<CacheAutofacModule>();
builder.RegisterType<LoggingLevelSwitch>().SingleInstance(); builder.RegisterType<CacheStoragePath>().As<ICacheStoragePath>();
builder.Register(c =>
{ ConfigurationRegistrations(builder);
var logPath = Path.Combine(AppPaths.LogDirectory, CommandRegistrations(builder);
$"trash_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.log");
SetupLogging(builder);
const string consoleTemplate = "[{Level:u3}] {Message:lj}{NewLine}{Exception}";
builder.RegisterModule<SonarrAutofacModule>();
return new LoggerConfiguration() builder.RegisterModule<RadarrAutofacModule>();
.MinimumLevel.Debug()
.WriteTo.Console(outputTemplate: consoleTemplate, levelSwitch: c.Resolve<LoggingLevelSwitch>()) builder.Register(_ => AutoMapperConfig.Setup()).SingleInstance();
.WriteTo.File(logPath)
.CreateLogger(); return builder.Build();
})
.As<ILogger>()
.SingleInstance();
}
private static void ConfigurationRegistrations(ContainerBuilder builder)
{
builder.RegisterModule<ConfigAutofacModule>();
builder.RegisterType<ObjectFactory>().As<IObjectFactory>();
builder.RegisterType<ResourcePaths>().As<IResourcePaths>();
builder.RegisterGeneric(typeof(ConfigurationLoader<>))
.WithProperty(new AutowiringParameter())
.As(typeof(IConfigurationLoader<>));
// note: Do not allow consumers to resolve IServiceConfiguration directly; if this gets cached
// they end up using the wrong configuration when multiple instances are used.
// builder.Register(c => c.Resolve<IConfigurationProvider>().ActiveConfiguration)
// .As<IServiceConfiguration>();
}
private static void CommandRegistrations(ContainerBuilder builder)
{
// Register all types deriving from CliFx's ICommand. These are all of our supported subcommands.
builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
.Where(t => t.IsAssignableTo(typeof(ICommand)));
// Used to access the chosen command class. This is assigned from CliTypeActivator
builder.RegisterType<ActiveServiceCommandProvider>()
.As<IActiveServiceCommandProvider>()
.SingleInstance();
}
public static IContainer Setup()
{
return Setup(new ContainerBuilder());
}
public static IContainer Setup(ContainerBuilder builder)
{
builder.RegisterType<FileSystem>().As<IFileSystem>();
builder.RegisterModule<CacheAutofacModule>();
builder.RegisterType<CacheStoragePath>().As<ICacheStoragePath>();
ConfigurationRegistrations(builder);
CommandRegistrations(builder);
SetupLogging(builder);
builder.RegisterModule<SonarrAutofacModule>();
builder.RegisterModule<RadarrAutofacModule>();
builder.Register(_ => AutoMapperConfig.Setup()).SingleInstance();
return builder.Build();
}
} }
} }

@ -5,52 +5,51 @@ using System.Runtime.Serialization;
using System.Text; using System.Text;
using FluentValidation.Results; using FluentValidation.Results;
namespace Trash.Config namespace Trash.Config;
[Serializable]
public class ConfigurationException : Exception
{ {
[Serializable] protected ConfigurationException(SerializationInfo info, StreamingContext context)
public class ConfigurationException : Exception : base(info, context)
{ {
protected ConfigurationException(SerializationInfo info, StreamingContext context) }
: base(info, context)
{
}
private ConfigurationException(string propertyName, Type deserializableType, IEnumerable<string> messages) private ConfigurationException(string propertyName, Type deserializableType, IEnumerable<string> messages)
{ {
PropertyName = propertyName; PropertyName = propertyName;
DeserializableType = deserializableType; DeserializableType = deserializableType;
ErrorMessages = messages.ToList(); ErrorMessages = messages.ToList();
} }
public ConfigurationException(string propertyName, Type deserializableType, string message) public ConfigurationException(string propertyName, Type deserializableType, string message)
: this(propertyName, deserializableType, new[] {message}) : this(propertyName, deserializableType, new[] {message})
{ {
} }
public ConfigurationException(string propertyName, Type deserializableType, public ConfigurationException(string propertyName, Type deserializableType,
IEnumerable<ValidationFailure> validationFailures) IEnumerable<ValidationFailure> validationFailures)
: this(propertyName, deserializableType, validationFailures.Select(e => e.ToString())) : this(propertyName, deserializableType, validationFailures.Select(e => e.ToString()))
{ {
} }
public IReadOnlyCollection<string> ErrorMessages { get; } = new List<string>(); public IReadOnlyCollection<string> ErrorMessages { get; } = new List<string>();
public string PropertyName { get; } = ""; public string PropertyName { get; } = "";
public Type DeserializableType { get; } = default!; public Type DeserializableType { get; } = default!;
public override string Message => BuildMessage(); public override string Message => BuildMessage();
private string BuildMessage() private string BuildMessage()
{
const string delim = "\n - ";
var builder = new StringBuilder(
$"An exception occurred while deserializing type '{DeserializableType}' for YML property '{PropertyName}'");
if (ErrorMessages.Count > 0)
{ {
const string delim = "\n - "; builder.Append(":" + delim);
var builder = new StringBuilder( builder.Append(string.Join(delim, ErrorMessages));
$"An exception occurred while deserializing type '{DeserializableType}' for YML property '{PropertyName}'");
if (ErrorMessages.Count > 0)
{
builder.Append(":" + delim);
builder.Append(string.Join(delim, ErrorMessages));
}
return builder.ToString();
} }
return builder.ToString();
} }
} }

@ -10,95 +10,94 @@ using YamlDotNet.Core.Events;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions; using YamlDotNet.Serialization.NamingConventions;
namespace Trash.Config namespace Trash.Config;
public class ConfigurationLoader<T> : IConfigurationLoader<T>
where T : IServiceConfiguration
{ {
public class ConfigurationLoader<T> : IConfigurationLoader<T> private readonly IConfigurationProvider _configProvider;
where T : IServiceConfiguration private readonly IDeserializer _deserializer;
private readonly IFileSystem _fileSystem;
private readonly IValidator<T> _validator;
public ConfigurationLoader(
IConfigurationProvider configProvider,
IFileSystem fileSystem,
IObjectFactory objectFactory,
IValidator<T> validator)
{ {
private readonly IConfigurationProvider _configProvider; _configProvider = configProvider;
private readonly IDeserializer _deserializer; _fileSystem = fileSystem;
private readonly IFileSystem _fileSystem; _validator = validator;
private readonly IValidator<T> _validator; _deserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.WithTypeConverter(new YamlNullableEnumTypeConverter())
.WithObjectFactory(objectFactory)
.Build();
}
public ConfigurationLoader( public IEnumerable<T> Load(string propertyName, string configSection)
IConfigurationProvider configProvider, {
IFileSystem fileSystem, using var stream = _fileSystem.File.OpenText(propertyName);
IObjectFactory objectFactory, return LoadFromStream(stream, configSection);
IValidator<T> validator) }
{
_configProvider = configProvider;
_fileSystem = fileSystem;
_validator = validator;
_deserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.WithTypeConverter(new YamlNullableEnumTypeConverter())
.WithObjectFactory(objectFactory)
.Build();
}
public IEnumerable<T> Load(string propertyName, string configSection) public IEnumerable<T> LoadFromStream(TextReader stream, string configSection)
{ {
using var stream = _fileSystem.File.OpenText(propertyName); var parser = new Parser(stream);
return LoadFromStream(stream, configSection); parser.Consume<StreamStart>();
} parser.Consume<DocumentStart>();
parser.Consume<MappingStart>();
public IEnumerable<T> LoadFromStream(TextReader stream, string configSection) var validConfigs = new List<T>();
while (parser.TryConsume<Scalar>(out var key))
{ {
var parser = new Parser(stream); if (key.Value != configSection)
parser.Consume<StreamStart>();
parser.Consume<DocumentStart>();
parser.Consume<MappingStart>();
var validConfigs = new List<T>();
while (parser.TryConsume<Scalar>(out var key))
{ {
if (key.Value != configSection)
{
parser.SkipThisAndNestedEvents();
continue;
}
var configs = _deserializer.Deserialize<List<T>?>(parser);
if (configs == null)
{
parser.SkipThisAndNestedEvents();
continue;
}
ValidateConfigs(configSection, configs, validConfigs);
parser.SkipThisAndNestedEvents(); parser.SkipThisAndNestedEvents();
continue;
} }
if (validConfigs.Count == 0) var configs = _deserializer.Deserialize<List<T>?>(parser);
if (configs == null)
{ {
throw new ConfigurationException(configSection, typeof(T), "There are no configured instances defined"); parser.SkipThisAndNestedEvents();
continue;
} }
return validConfigs; ValidateConfigs(configSection, configs, validConfigs);
parser.SkipThisAndNestedEvents();
} }
private void ValidateConfigs(string configSection, IEnumerable<T> configs, ICollection<T> validConfigs) if (validConfigs.Count == 0)
{ {
foreach (var config in configs) throw new ConfigurationException(configSection, typeof(T), "There are no configured instances defined");
{
var result = _validator.Validate(config);
if (result is {IsValid: false})
{
throw new ConfigurationException(configSection, typeof(T), result.Errors);
}
validConfigs.Add(config);
}
} }
public IEnumerable<T> LoadMany(IEnumerable<string> configFiles, string configSection) return validConfigs;
}
private void ValidateConfigs(string configSection, IEnumerable<T> configs, ICollection<T> validConfigs)
{
foreach (var config in configs)
{ {
foreach (var config in configFiles.SelectMany(file => Load(file, configSection))) var result = _validator.Validate(config);
if (result is {IsValid: false})
{ {
_configProvider.ActiveConfiguration = config; throw new ConfigurationException(configSection, typeof(T), result.Errors);
yield return config;
} }
validConfigs.Add(config);
}
}
public IEnumerable<T> LoadMany(IEnumerable<string> configFiles, string configSection)
{
foreach (var config in configFiles.SelectMany(file => Load(file, configSection)))
{
_configProvider.ActiveConfiguration = config;
yield return config;
} }
} }
} }

@ -2,13 +2,12 @@
using System.IO; using System.IO;
using TrashLib.Config; using TrashLib.Config;
namespace Trash.Config namespace Trash.Config;
public interface IConfigurationLoader<out T>
where T : IServiceConfiguration
{ {
public interface IConfigurationLoader<out T> IEnumerable<T> Load(string propertyName, string configSection);
where T : IServiceConfiguration IEnumerable<T> LoadFromStream(TextReader stream, string configSection);
{ IEnumerable<T> LoadMany(IEnumerable<string> configFiles, string configSection);
IEnumerable<T> Load(string propertyName, string configSection);
IEnumerable<T> LoadFromStream(TextReader stream, string configSection);
IEnumerable<T> LoadMany(IEnumerable<string> configFiles, string configSection);
}
} }

@ -3,21 +3,20 @@ using Autofac;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
using YamlDotNet.Serialization.ObjectFactories; using YamlDotNet.Serialization.ObjectFactories;
namespace Trash.Config namespace Trash.Config;
public class ObjectFactory : IObjectFactory
{ {
public class ObjectFactory : IObjectFactory private readonly ILifetimeScope _container;
{ private readonly DefaultObjectFactory _defaultFactory = new();
private readonly ILifetimeScope _container;
private readonly DefaultObjectFactory _defaultFactory = new();
public ObjectFactory(ILifetimeScope container) public ObjectFactory(ILifetimeScope container)
{ {
_container = container; _container = container;
} }
public object Create(Type type) public object Create(Type type)
{ {
return _container.IsRegistered(type) ? _container.Resolve(type) : _defaultFactory.Create(type); return _container.IsRegistered(type) ? _container.Resolve(type) : _defaultFactory.Create(type);
}
} }
} }

@ -1,7 +1,6 @@
namespace Trash namespace Trash;
public interface ILogJanitor
{ {
public interface ILogJanitor void DeleteOldestLogFiles(int numberOfNewestToKeep);
{
void DeleteOldestLogFiles(int numberOfNewestToKeep);
}
} }

@ -1,25 +1,24 @@
using System.IO.Abstractions; using System.IO.Abstractions;
using System.Linq; using System.Linq;
namespace Trash namespace Trash;
public class LogJanitor : ILogJanitor
{ {
public class LogJanitor : ILogJanitor private readonly IFileSystem _fileSystem;
{
private readonly IFileSystem _fileSystem;
public LogJanitor(IFileSystem fileSystem) public LogJanitor(IFileSystem fileSystem)
{ {
_fileSystem = fileSystem; _fileSystem = fileSystem;
} }
public void DeleteOldestLogFiles(int numberOfNewestToKeep) public void DeleteOldestLogFiles(int numberOfNewestToKeep)
{
foreach (var file in _fileSystem.DirectoryInfo.FromDirectoryName(AppPaths.LogDirectory).GetFiles()
.OrderByDescending(f => f.Name)
.Skip(numberOfNewestToKeep))
{ {
foreach (var file in _fileSystem.DirectoryInfo.FromDirectoryName(AppPaths.LogDirectory).GetFiles() file.Delete();
.OrderByDescending(f => f.Name)
.Skip(numberOfNewestToKeep))
{
file.Delete();
}
} }
} }
} }

@ -3,22 +3,21 @@ using Autofac;
using CliFx; using CliFx;
using Trash.Command.Helpers; using Trash.Command.Helpers;
namespace Trash namespace Trash;
internal static class Program
{ {
internal static class Program private static IContainer? _container;
{
private static IContainer? _container;
public static async Task<int> Main() public static async Task<int> Main()
{ {
_container = CompositionRoot.Setup(); _container = CompositionRoot.Setup();
return await new CliApplicationBuilder() return await new CliApplicationBuilder()
.AddCommandsFromThisAssembly() .AddCommandsFromThisAssembly()
.SetExecutableName(ThisAssembly.AssemblyName) .SetExecutableName(ThisAssembly.AssemblyName)
.SetVersion($"v{ThisAssembly.AssemblyInformationalVersion}") .SetVersion($"v{ThisAssembly.AssemblyInformationalVersion}")
.UseTypeActivator(type => CliTypeActivator.ResolveType(_container, type)) .UseTypeActivator(type => CliTypeActivator.ResolveType(_container, type))
.Build() .Build()
.RunAsync(); .RunAsync();
}
} }
} }

@ -1,9 +1,8 @@
using TrashLib.Radarr.Config; using TrashLib.Radarr.Config;
namespace Trash namespace Trash;
public class ResourcePaths : IResourcePaths
{ {
public class ResourcePaths : IResourcePaths public string RepoPath => AppPaths.RepoDirectory;
{
public string RepoPath => AppPaths.RepoDirectory;
}
} }

@ -1,14 +1,13 @@
using System.Linq; using System.Linq;
using TrashLib.Radarr.CustomFormat.Models; using TrashLib.Radarr.CustomFormat.Models;
namespace Trash.TestLibrary namespace Trash.TestLibrary;
public static class CfTestUtils
{ {
public static class CfTestUtils public static QualityProfileCustomFormatScoreMapping NewMapping(params FormatMappingEntry[] entries)
{ => new(false) {Mapping = entries.ToList()};
public static QualityProfileCustomFormatScoreMapping NewMapping(params FormatMappingEntry[] entries)
=> new(false) {Mapping = entries.ToList()};
public static QualityProfileCustomFormatScoreMapping NewMappingWithReset(params FormatMappingEntry[] entries) public static QualityProfileCustomFormatScoreMapping NewMappingWithReset(params FormatMappingEntry[] entries)
=> new(true) {Mapping = entries.ToList()}; => new(true) {Mapping = entries.ToList()};
}
} }

@ -12,201 +12,200 @@ using TestLibrary.NSubstitute;
using TrashLib.Cache; using TrashLib.Cache;
using TrashLib.Config; using TrashLib.Config;
namespace TrashLib.Tests.Cache namespace TrashLib.Tests.Cache;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ServiceCacheTest
{ {
[TestFixture] private class Context
[Parallelizable(ParallelScope.All)]
public class ServiceCacheTest
{ {
private class Context public Context(IFileSystem? fs = null)
{ {
public Context(IFileSystem? fs = null) Filesystem = fs ?? Substitute.For<IFileSystem>();
StoragePath = Substitute.For<ICacheStoragePath>();
ConfigProvider = Substitute.For<IConfigurationProvider>();
JsonSettings = new JsonSerializerSettings
{ {
Filesystem = fs ?? Substitute.For<IFileSystem>(); Formatting = Formatting.Indented,
StoragePath = Substitute.For<ICacheStoragePath>(); ContractResolver = new DefaultContractResolver
ConfigProvider = Substitute.For<IConfigurationProvider>();
JsonSettings = new JsonSerializerSettings
{ {
Formatting = Formatting.Indented, NamingStrategy = new SnakeCaseNamingStrategy()
ContractResolver = new DefaultContractResolver }
{ };
NamingStrategy = new SnakeCaseNamingStrategy()
}
};
// Set up a default for the active config's base URL. This is used to generate part of the path
ConfigProvider.ActiveConfiguration = Substitute.For<IServiceConfiguration>();
ConfigProvider.ActiveConfiguration.BaseUrl.Returns("http://localhost:1234");
Cache = new ServiceCache(Filesystem, StoragePath, ConfigProvider, Substitute.For<ILogger>());
}
public JsonSerializerSettings JsonSettings { get; }
public ServiceCache Cache { get; }
public IConfigurationProvider ConfigProvider { get; }
public ICacheStoragePath StoragePath { get; }
public IFileSystem Filesystem { get; }
}
private class ObjectWithoutAttribute // Set up a default for the active config's base URL. This is used to generate part of the path
{ ConfigProvider.ActiveConfiguration = Substitute.For<IServiceConfiguration>();
ConfigProvider.ActiveConfiguration.BaseUrl.Returns("http://localhost:1234");
Cache = new ServiceCache(Filesystem, StoragePath, ConfigProvider, Substitute.For<ILogger>());
} }
private const string ValidObjectName = "azAZ_09"; public JsonSerializerSettings JsonSettings { get; }
public ServiceCache Cache { get; }
public IConfigurationProvider ConfigProvider { get; }
public ICacheStoragePath StoragePath { get; }
public IFileSystem Filesystem { get; }
}
[CacheObjectName(ValidObjectName)] private class ObjectWithoutAttribute
private class ObjectWithAttribute {
{ }
public string TestValue { get; init; } = "";
}
[CacheObjectName("invalid+name")] private const string ValidObjectName = "azAZ_09";
private class ObjectWithAttributeInvalidChars
{
}
[Test] [CacheObjectName(ValidObjectName)]
public void Load_returns_null_when_file_does_not_exist() private class ObjectWithAttribute
{ {
var ctx = new Context(); public string TestValue { get; init; } = "";
ctx.Filesystem.File.Exists(Arg.Any<string>()).Returns(false); }
var result = ctx.Cache.Load<ObjectWithAttribute>(); [CacheObjectName("invalid+name")]
result.Should().BeNull(); private class ObjectWithAttributeInvalidChars
} {
}
[Test] [Test]
public void Loading_with_attribute_parses_correctly() public void Load_returns_null_when_file_does_not_exist()
{ {
var ctx = new Context(); var ctx = new Context();
ctx.Filesystem.File.Exists(Arg.Any<string>()).Returns(false);
var result = ctx.Cache.Load<ObjectWithAttribute>();
result.Should().BeNull();
}
ctx.StoragePath.Path.Returns("testpath"); [Test]
public void Loading_with_attribute_parses_correctly()
{
var ctx = new Context();
dynamic testJson = new {TestValue = "Foo"}; ctx.StoragePath.Path.Returns("testpath");
ctx.Filesystem.File.Exists(Arg.Any<string>()).Returns(true);
ctx.Filesystem.File.ReadAllText(Arg.Any<string>())
.Returns(_ => JsonConvert.SerializeObject(testJson));
var obj = ctx.Cache.Load<ObjectWithAttribute>(); dynamic testJson = new {TestValue = "Foo"};
ctx.Filesystem.File.Exists(Arg.Any<string>()).Returns(true);
ctx.Filesystem.File.ReadAllText(Arg.Any<string>())
.Returns(_ => JsonConvert.SerializeObject(testJson));
obj.Should().NotBeNull(); var obj = ctx.Cache.Load<ObjectWithAttribute>();
obj!.TestValue.Should().Be("Foo");
ctx.Filesystem.File.Received().ReadAllText(Path.Combine("testpath", "be8fbc8f", $"{ValidObjectName}.json"));
}
[Test] obj.Should().NotBeNull();
public void Loading_with_invalid_object_name_throws() obj!.TestValue.Should().Be("Foo");
{ ctx.Filesystem.File.Received().ReadAllText(Path.Combine("testpath", "be8fbc8f", $"{ValidObjectName}.json"));
var ctx = new Context(); }
Action act = () => ctx.Cache.Load<ObjectWithAttributeInvalidChars>(); [Test]
public void Loading_with_invalid_object_name_throws()
{
var ctx = new Context();
act.Should() Action act = () => ctx.Cache.Load<ObjectWithAttributeInvalidChars>();
.Throw<ArgumentException>()
.WithMessage("*'invalid+name' has unacceptable characters*");
}
[Test] act.Should()
public void Loading_without_attribute_throws() .Throw<ArgumentException>()
{ .WithMessage("*'invalid+name' has unacceptable characters*");
var ctx = new Context(); }
Action act = () => ctx.Cache.Load<ObjectWithoutAttribute>(); [Test]
public void Loading_without_attribute_throws()
{
var ctx = new Context();
act.Should() Action act = () => ctx.Cache.Load<ObjectWithoutAttribute>();
.Throw<ArgumentException>()
.WithMessage("CacheObjectNameAttribute is missing*");
}
[Test] act.Should()
public void Properties_are_saved_using_snake_case() .Throw<ArgumentException>()
{ .WithMessage("CacheObjectNameAttribute is missing*");
var ctx = new Context(); }
ctx.StoragePath.Path.Returns("testpath");
ctx.Cache.Save(new ObjectWithAttribute {TestValue = "Foo"});
ctx.Filesystem.File.Received() [Test]
.WriteAllText(Arg.Any<string>(), Verify.That<string>(json => json.Should().Contain("\"test_value\""))); public void Properties_are_saved_using_snake_case()
} {
var ctx = new Context();
ctx.StoragePath.Path.Returns("testpath");
ctx.Cache.Save(new ObjectWithAttribute {TestValue = "Foo"});
[Test] ctx.Filesystem.File.Received()
public void Saving_with_attribute_parses_correctly() .WriteAllText(Arg.Any<string>(), Verify.That<string>(json => json.Should().Contain("\"test_value\"")));
{ }
var ctx = new Context();
ctx.StoragePath.Path.Returns("testpath"); [Test]
public void Saving_with_attribute_parses_correctly()
{
var ctx = new Context();
ctx.Cache.Save(new ObjectWithAttribute {TestValue = "Foo"}); ctx.StoragePath.Path.Returns("testpath");
var expectedParentDirectory = Path.Combine("testpath", "be8fbc8f"); ctx.Cache.Save(new ObjectWithAttribute {TestValue = "Foo"});
ctx.Filesystem.Directory.Received().CreateDirectory(expectedParentDirectory);
dynamic expectedJson = new {TestValue = "Foo"}; var expectedParentDirectory = Path.Combine("testpath", "be8fbc8f");
var expectedPath = Path.Combine(expectedParentDirectory, $"{ValidObjectName}.json"); ctx.Filesystem.Directory.Received().CreateDirectory(expectedParentDirectory);
ctx.Filesystem.File.Received()
.WriteAllText(expectedPath, JsonConvert.SerializeObject(expectedJson, ctx.JsonSettings));
}
[Test] dynamic expectedJson = new {TestValue = "Foo"};
public void Saving_with_invalid_object_name_throws() var expectedPath = Path.Combine(expectedParentDirectory, $"{ValidObjectName}.json");
{ ctx.Filesystem.File.Received()
var ctx = new Context(); .WriteAllText(expectedPath, JsonConvert.SerializeObject(expectedJson, ctx.JsonSettings));
}
[Test]
public void Saving_with_invalid_object_name_throws()
{
var ctx = new Context();
Action act = () => ctx.Cache.Save(new ObjectWithAttributeInvalidChars()); var act = () => ctx.Cache.Save(new ObjectWithAttributeInvalidChars());
act.Should() act.Should()
.Throw<ArgumentException>() .Throw<ArgumentException>()
.WithMessage("*'invalid+name' has unacceptable characters*"); .WithMessage("*'invalid+name' has unacceptable characters*");
} }
[Test] [Test]
public void Saving_without_attribute_throws() public void Saving_without_attribute_throws()
{ {
var ctx = new Context(); var ctx = new Context();
Action act = () => ctx.Cache.Save(new ObjectWithoutAttribute()); var act = () => ctx.Cache.Save(new ObjectWithoutAttribute());
act.Should() act.Should()
.Throw<ArgumentException>() .Throw<ArgumentException>()
.WithMessage("CacheObjectNameAttribute is missing*"); .WithMessage("CacheObjectNameAttribute is missing*");
} }
[Test] [Test]
public void Switching_config_and_base_url_should_yield_different_cache_paths() public void Switching_config_and_base_url_should_yield_different_cache_paths()
{ {
var ctx = new Context(); var ctx = new Context();
ctx.StoragePath.Path.Returns("testpath"); ctx.StoragePath.Path.Returns("testpath");
var actualPaths = new List<string>(); var actualPaths = new List<string>();
dynamic testJson = new {TestValue = "Foo"}; dynamic testJson = new {TestValue = "Foo"};
ctx.Filesystem.File.Exists(Arg.Any<string>()).Returns(true); ctx.Filesystem.File.Exists(Arg.Any<string>()).Returns(true);
ctx.Filesystem.File.ReadAllText(Arg.Do<string>(s => actualPaths.Add(s))) ctx.Filesystem.File.ReadAllText(Arg.Do<string>(s => actualPaths.Add(s)))
.Returns(_ => JsonConvert.SerializeObject(testJson)); .Returns(_ => JsonConvert.SerializeObject(testJson));
ctx.Cache.Load<ObjectWithAttribute>(); ctx.Cache.Load<ObjectWithAttribute>();
// Change the active config & base URL so we get a different path // Change the active config & base URL so we get a different path
ctx.ConfigProvider.ActiveConfiguration = Substitute.For<IServiceConfiguration>(); ctx.ConfigProvider.ActiveConfiguration = Substitute.For<IServiceConfiguration>();
ctx.ConfigProvider.ActiveConfiguration.BaseUrl.Returns("http://localhost:5678"); ctx.ConfigProvider.ActiveConfiguration.BaseUrl.Returns("http://localhost:5678");
ctx.Cache.Load<ObjectWithAttribute>(); ctx.Cache.Load<ObjectWithAttribute>();
actualPaths.Count.Should().Be(2); actualPaths.Count.Should().Be(2);
actualPaths.Should().OnlyHaveUniqueItems(); actualPaths.Should().OnlyHaveUniqueItems();
} }
[Test] [Test]
public void When_cache_file_is_empty_do_not_throw() public void When_cache_file_is_empty_do_not_throw()
{ {
var ctx = new Context(); var ctx = new Context();
ctx.Filesystem.File.Exists(Arg.Any<string>()).Returns(true); ctx.Filesystem.File.Exists(Arg.Any<string>()).Returns(true);
ctx.Filesystem.File.ReadAllText(Arg.Any<string>()) ctx.Filesystem.File.ReadAllText(Arg.Any<string>())
.Returns(_ => ""); .Returns(_ => "");
Action act = () => ctx.Cache.Load<ObjectWithAttribute>(); Action act = () => ctx.Cache.Load<ObjectWithAttribute>();
act.Should().NotThrow(); act.Should().NotThrow();
}
} }
} }

@ -11,143 +11,142 @@ using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache; using TrashLib.Radarr.CustomFormat.Models.Cache;
using TrashLib.Radarr.CustomFormat.Processors.PersistenceSteps; using TrashLib.Radarr.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Tests.Radarr.CustomFormat namespace TrashLib.Tests.Radarr.CustomFormat;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CachePersisterTest
{ {
[TestFixture] private class Context
[Parallelizable(ParallelScope.All)]
public class CachePersisterTest
{ {
private class Context public Context()
{ {
public Context() var log = Substitute.For<ILogger>();
{ ServiceCache = Substitute.For<IServiceCache>();
var log = Substitute.For<ILogger>(); Persister = new CachePersister(log, ServiceCache);
ServiceCache = Substitute.For<IServiceCache>();
Persister = new CachePersister(log, ServiceCache);
}
public CachePersister Persister { get; }
public IServiceCache ServiceCache { get; }
} }
private static ProcessedCustomFormatData QuickMakeCf(string cfName, string trashId, int cfId) public CachePersister Persister { get; }
{ public IServiceCache ServiceCache { get; }
return new ProcessedCustomFormatData(cfName, trashId, new JObject()) }
{
CacheEntry = new TrashIdMapping(trashId, cfName) {CustomFormatId = cfId}
};
}
[TestCase(CustomFormatCache.LatestVersion - 1)] private static ProcessedCustomFormatData QuickMakeCf(string cfName, string trashId, int cfId)
[TestCase(CustomFormatCache.LatestVersion + 1)] {
public void Set_loaded_cache_to_null_if_versions_mismatch(int versionToTest) return new ProcessedCustomFormatData(cfName, trashId, new JObject())
{ {
var ctx = new Context(); CacheEntry = new TrashIdMapping(trashId, cfName) {CustomFormatId = cfId}
};
var testCfObj = new CustomFormatCache }
{
Version = versionToTest,
TrashIdMappings = new Collection<TrashIdMapping> {new("", "", 5)}
};
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
ctx.Persister.Load();
ctx.Persister.CfCache.Should().BeNull();
}
[Test] [TestCase(CustomFormatCache.LatestVersion - 1)]
public void Accept_loaded_cache_when_versions_match() [TestCase(CustomFormatCache.LatestVersion + 1)]
{ public void Set_loaded_cache_to_null_if_versions_mismatch(int versionToTest)
var ctx = new Context(); {
var ctx = new Context();
var testCfObj = new CustomFormatCache
{
Version = CustomFormatCache.LatestVersion,
TrashIdMappings = new Collection<TrashIdMapping> {new("", "", 5)}
};
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
ctx.Persister.Load();
ctx.Persister.CfCache.Should().NotBeNull();
}
[Test] var testCfObj = new CustomFormatCache
public void Cf_cache_is_valid_after_successful_load()
{ {
var ctx = new Context(); Version = versionToTest,
var testCfObj = new CustomFormatCache(); TrashIdMappings = new Collection<TrashIdMapping> {new("", "", 5)}
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj); };
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
ctx.Persister.Load();
ctx.Persister.CfCache.Should().BeNull();
}
ctx.Persister.Load(); [Test]
ctx.Persister.CfCache.Should().BeSameAs(testCfObj); public void Accept_loaded_cache_when_versions_match()
} {
var ctx = new Context();
[Test] var testCfObj = new CustomFormatCache
public void Cf_cache_returns_null_if_not_loaded()
{ {
var ctx = new Context(); Version = CustomFormatCache.LatestVersion,
ctx.Persister.Load(); TrashIdMappings = new Collection<TrashIdMapping> {new("", "", 5)}
ctx.Persister.CfCache.Should().BeNull(); };
} ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
ctx.Persister.Load();
ctx.Persister.CfCache.Should().NotBeNull();
}
[Test] [Test]
public void Save_works_with_valid_cf_cache() public void Cf_cache_is_valid_after_successful_load()
{ {
var ctx = new Context(); var ctx = new Context();
var testCfObj = new CustomFormatCache(); var testCfObj = new CustomFormatCache();
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj); ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
ctx.Persister.Load(); ctx.Persister.Load();
ctx.Persister.Save(); ctx.Persister.CfCache.Should().BeSameAs(testCfObj);
}
ctx.ServiceCache.Received().Save(Arg.Is(testCfObj)); [Test]
} public void Cf_cache_returns_null_if_not_loaded()
{
var ctx = new Context();
ctx.Persister.Load();
ctx.Persister.CfCache.Should().BeNull();
}
[Test]
public void Save_works_with_valid_cf_cache()
{
var ctx = new Context();
var testCfObj = new CustomFormatCache();
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
ctx.Persister.Load();
ctx.Persister.Save();
ctx.ServiceCache.Received().Save(Arg.Is(testCfObj));
}
[Test] [Test]
public void Saving_without_loading_does_nothing() public void Saving_without_loading_does_nothing()
{
var ctx = new Context();
ctx.Persister.Save();
ctx.ServiceCache.DidNotReceive().Save(Arg.Any<object>());
}
[Test]
public void Updating_overwrites_previous_cf_cache_and_updates_cf_data()
{
var ctx = new Context();
// Load initial CfCache just to test that it gets replaced
var testCfObj = new CustomFormatCache
{ {
var ctx = new Context(); TrashIdMappings = new Collection<TrashIdMapping> {new("", "") {CustomFormatId = 5}}
ctx.Persister.Save(); };
ctx.ServiceCache.DidNotReceive().Save(Arg.Any<object>()); ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
} ctx.Persister.Load();
// Update with new cached items
var results = new CustomFormatTransactionData();
results.NewCustomFormats.Add(QuickMakeCf("cfname", "trashid", 10));
[Test] var customFormatData = new List<ProcessedCustomFormatData>
public void Updating_overwrites_previous_cf_cache_and_updates_cf_data()
{ {
var ctx = new Context(); new("", "trashid", new JObject()) {CacheEntry = new TrashIdMapping("trashid", "cfname", 10)}
};
// Load initial CfCache just to test that it gets replaced
var testCfObj = new CustomFormatCache
{
TrashIdMappings = new Collection<TrashIdMapping> {new("", "") {CustomFormatId = 5}}
};
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
ctx.Persister.Load();
// Update with new cached items
var results = new CustomFormatTransactionData();
results.NewCustomFormats.Add(QuickMakeCf("cfname", "trashid", 10));
var customFormatData = new List<ProcessedCustomFormatData>
{
new("", "trashid", new JObject()) {CacheEntry = new TrashIdMapping("trashid", "cfname", 10)}
};
ctx.Persister.Update(customFormatData);
ctx.Persister.CfCache.Should().BeEquivalentTo(new CustomFormatCache
{
TrashIdMappings = new Collection<TrashIdMapping> {customFormatData[0].CacheEntry!}
});
customFormatData.Should().ContainSingle()
.Which.CacheEntry.Should().BeEquivalentTo(
new TrashIdMapping("trashid", "cfname") {CustomFormatId = 10});
}
[Test] ctx.Persister.Update(customFormatData);
public void Updating_sets_cf_cache_without_loading() ctx.Persister.CfCache.Should().BeEquivalentTo(new CustomFormatCache
{ {
var ctx = new Context(); TrashIdMappings = new Collection<TrashIdMapping> {customFormatData[0].CacheEntry!}
ctx.Persister.Update(new List<ProcessedCustomFormatData>()); });
ctx.Persister.CfCache.Should().NotBeNull();
} customFormatData.Should().ContainSingle()
.Which.CacheEntry.Should().BeEquivalentTo(
new TrashIdMapping("trashid", "cfname") {CustomFormatId = 10});
}
[Test]
public void Updating_sets_cf_cache_without_loading()
{
var ctx = new Context();
ctx.Persister.Update(new List<ProcessedCustomFormatData>());
ctx.Persister.CfCache.Should().NotBeNull();
} }
} }

@ -15,147 +15,146 @@ using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Processors; using TrashLib.Radarr.CustomFormat.Processors;
using TrashLib.Radarr.CustomFormat.Processors.GuideSteps; using TrashLib.Radarr.CustomFormat.Processors.GuideSteps;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors namespace TrashLib.Tests.Radarr.CustomFormat.Processors;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class GuideProcessorTest
{ {
[TestFixture] private class TestGuideProcessorSteps : IGuideProcessorSteps
[Parallelizable(ParallelScope.All)]
public class GuideProcessorTest
{ {
private class TestGuideProcessorSteps : IGuideProcessorSteps public ICustomFormatStep CustomFormat { get; } = new CustomFormatStep();
public IConfigStep Config { get; } = new ConfigStep();
public IQualityProfileStep QualityProfile { get; } = new QualityProfileStep();
}
private class Context
{
public Context()
{ {
public ICustomFormatStep CustomFormat { get; } = new CustomFormatStep(); Logger = new LoggerConfiguration()
public IConfigStep Config { get; } = new ConfigStep(); .WriteTo.TestCorrelator()
public IQualityProfileStep QualityProfile { get; } = new QualityProfileStep(); .WriteTo.NUnitOutput()
.MinimumLevel.Debug()
.CreateLogger();
Data = new ResourceDataReader(typeof(GuideProcessorTest), "Data");
} }
private class Context public ILogger Logger { get; }
{ public ResourceDataReader Data { get; }
public Context()
{
Logger = new LoggerConfiguration()
.WriteTo.TestCorrelator()
.WriteTo.NUnitOutput()
.MinimumLevel.Debug()
.CreateLogger();
Data = new ResourceDataReader(typeof(GuideProcessorTest), "Data"); public string ReadText(string textFile) => Data.ReadData(textFile);
} public JObject ReadJson(string jsonFile) => JObject.Parse(ReadText(jsonFile));
}
public ILogger Logger { get; } [Test]
public ResourceDataReader Data { get; } [SuppressMessage("Maintainability", "CA1506", Justification = "Designed to be a high-level integration test")]
public async Task Guide_processor_behaves_as_expected_with_normal_guide_data()
{
var ctx = new Context();
var guideService = Substitute.For<IRadarrGuideService>();
var guideProcessor = new GuideProcessor(ctx.Logger, guideService, () => new TestGuideProcessorSteps());
public string ReadText(string textFile) => Data.ReadData(textFile); // simulate guide data
public JObject ReadJson(string jsonFile) => JObject.Parse(ReadText(jsonFile)); guideService.GetCustomFormatJsonAsync().Returns(new[]
} {
ctx.ReadText("ImportableCustomFormat1.json"),
ctx.ReadText("ImportableCustomFormat2.json"),
ctx.ReadText("NoScore.json"),
ctx.ReadText("WontBeInConfig.json")
});
[Test] // Simulate user config in YAML
[SuppressMessage("Maintainability", "CA1506", Justification = "Designed to be a high-level integration test")] var config = new List<CustomFormatConfig>
public async Task Guide_processor_behaves_as_expected_with_normal_guide_data()
{ {
var ctx = new Context(); new()
var guideService = Substitute.For<IRadarrGuideService>(); {
var guideProcessor = new GuideProcessor(ctx.Logger, guideService, () => new TestGuideProcessorSteps()); Names = new List<string> {"Surround SOUND", "DTS-HD/DTS:X", "no score", "not in guide 1"},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1"},
new() {Name = "profile2", Score = -1234}
}
},
new()
{
Names = new List<string> {"no score", "not in guide 2"},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile3"},
new() {Name = "profile4", Score = 5678}
}
}
};
// simulate guide data await guideProcessor.BuildGuideDataAsync(config, null);
guideService.GetCustomFormatJsonAsync().Returns(new[]
var expectedProcessedCustomFormatData = new List<ProcessedCustomFormatData>
{
new("Surround Sound", "43bb5f09c79641e7a22e48d440bd8868", ctx.ReadJson(
"ImportableCustomFormat1_Processed.json"))
{ {
ctx.ReadText("ImportableCustomFormat1.json"), Score = 500
ctx.ReadText("ImportableCustomFormat2.json"), },
ctx.ReadText("NoScore.json"), new("DTS-HD/DTS:X", "4eb3c272d48db8ab43c2c85283b69744", ctx.ReadJson(
ctx.ReadText("WontBeInConfig.json") "ImportableCustomFormat2_Processed.json"))
}); {
Score = 480
},
new("No Score", "abc", JObject.FromObject(new {name = "No Score"}))
};
guideProcessor.ProcessedCustomFormats.Should()
.BeEquivalentTo(expectedProcessedCustomFormatData, op => op.Using(new JsonEquivalencyStep()));
// Simulate user config in YAML guideProcessor.ConfigData.Should()
var config = new List<CustomFormatConfig> .BeEquivalentTo(new List<ProcessedConfigData>
{ {
new() new()
{ {
Names = new List<string> {"Surround SOUND", "DTS-HD/DTS:X", "no score", "not in guide 1"}, CustomFormats = expectedProcessedCustomFormatData,
QualityProfiles = new List<QualityProfileConfig> QualityProfiles = config[0].QualityProfiles
{
new() {Name = "profile1"},
new() {Name = "profile2", Score = -1234}
}
}, },
new() new()
{ {
Names = new List<string> {"no score", "not in guide 2"}, CustomFormats = expectedProcessedCustomFormatData.GetRange(2, 1),
QualityProfiles = new List<QualityProfileConfig> QualityProfiles = config[1].QualityProfiles
{
new() {Name = "profile3"},
new() {Name = "profile4", Score = 5678}
}
} }
}; }, op => op.Using(new JsonEquivalencyStep()));
await guideProcessor.BuildGuideDataAsync(config, null); guideProcessor.CustomFormatsWithoutScore.Should()
.Equal(new List<(string name, string trashId, string profileName)>
{
("No Score", "abc", "profile1"),
("No Score", "abc", "profile3")
});
var expectedProcessedCustomFormatData = new List<ProcessedCustomFormatData> guideProcessor.CustomFormatsNotInGuide.Should().Equal(new List<string>
{
"not in guide 1", "not in guide 2"
});
guideProcessor.ProfileScores.Should()
.BeEquivalentTo(new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{ {
new("Surround Sound", "43bb5f09c79641e7a22e48d440bd8868", ctx.ReadJson(
"ImportableCustomFormat1_Processed.json"))
{ {
Score = 500 "profile1", CfTestUtils.NewMapping(
new FormatMappingEntry(expectedProcessedCustomFormatData[0], 500),
new FormatMappingEntry(expectedProcessedCustomFormatData[1], 480))
}, },
new("DTS-HD/DTS:X", "4eb3c272d48db8ab43c2c85283b69744", ctx.ReadJson(
"ImportableCustomFormat2_Processed.json"))
{ {
Score = 480 "profile2", CfTestUtils.NewMapping(
new FormatMappingEntry(expectedProcessedCustomFormatData[0], -1234),
new FormatMappingEntry(expectedProcessedCustomFormatData[1], -1234),
new FormatMappingEntry(expectedProcessedCustomFormatData[2], -1234))
}, },
new("No Score", "abc", JObject.FromObject(new {name = "No Score"}))
};
guideProcessor.ProcessedCustomFormats.Should()
.BeEquivalentTo(expectedProcessedCustomFormatData, op => op.Using(new JsonEquivalencyStep()));
guideProcessor.ConfigData.Should()
.BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{
CustomFormats = expectedProcessedCustomFormatData,
QualityProfiles = config[0].QualityProfiles
},
new()
{
CustomFormats = expectedProcessedCustomFormatData.GetRange(2, 1),
QualityProfiles = config[1].QualityProfiles
}
}, op => op.Using(new JsonEquivalencyStep()));
guideProcessor.CustomFormatsWithoutScore.Should()
.Equal(new List<(string name, string trashId, string profileName)>
{ {
("No Score", "abc", "profile1"), "profile4", CfTestUtils.NewMapping(
("No Score", "abc", "profile3") new FormatMappingEntry(expectedProcessedCustomFormatData[2], 5678))
}); }
}, op => op
guideProcessor.CustomFormatsNotInGuide.Should().Equal(new List<string> .Using(new JsonEquivalencyStep())
{ .ComparingByMembers<FormatMappingEntry>());
"not in guide 1", "not in guide 2"
});
guideProcessor.ProfileScores.Should()
.BeEquivalentTo(new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{
"profile1", CfTestUtils.NewMapping(
new FormatMappingEntry(expectedProcessedCustomFormatData[0], 500),
new FormatMappingEntry(expectedProcessedCustomFormatData[1], 480))
},
{
"profile2", CfTestUtils.NewMapping(
new FormatMappingEntry(expectedProcessedCustomFormatData[0], -1234),
new FormatMappingEntry(expectedProcessedCustomFormatData[1], -1234),
new FormatMappingEntry(expectedProcessedCustomFormatData[2], -1234))
},
{
"profile4", CfTestUtils.NewMapping(
new FormatMappingEntry(expectedProcessedCustomFormatData[2], 5678))
}
}, op => op
.Using(new JsonEquivalencyStep())
.ComparingByMembers<FormatMappingEntry>());
}
} }
} }

@ -7,219 +7,218 @@ using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache; using TrashLib.Radarr.CustomFormat.Models.Cache;
using TrashLib.Radarr.CustomFormat.Processors.GuideSteps; using TrashLib.Radarr.CustomFormat.Processors.GuideSteps;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.GuideSteps namespace TrashLib.Tests.Radarr.CustomFormat.Processors.GuideSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigStepTest
{ {
[TestFixture] [Test]
[Parallelizable(ParallelScope.All)] public void Cache_names_are_used_instead_of_name_in_json_data()
public class ConfigStepTest
{ {
[Test] var testProcessedCfs = new List<ProcessedCustomFormatData>
public void Cache_names_are_used_instead_of_name_in_json_data()
{ {
var testProcessedCfs = new List<ProcessedCustomFormatData> new("name1", "id1", JObject.FromObject(new {name = "name1"}))
{ {
new("name1", "id1", JObject.FromObject(new {name = "name1"})) Score = 100
{ },
Score = 100 new("name3", "id3", JObject.FromObject(new {name = "name3"}))
}, {
new("name3", "id3", JObject.FromObject(new {name = "name3"})) CacheEntry = new TrashIdMapping("id3", "name1")
{ }
CacheEntry = new TrashIdMapping("id3", "name1") };
}
};
var testConfig = new CustomFormatConfig[] var testConfig = new CustomFormatConfig[]
{
new()
{ {
new() Names = new List<string> {"name1"}
{ }
Names = new List<string> {"name1"} };
}
};
var processor = new ConfigStep(); var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig); processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.Should().BeEmpty(); processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData> processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{ {
new() new()
{ {
CustomFormats = new List<ProcessedCustomFormatData> CustomFormats = new List<ProcessedCustomFormatData>
{testProcessedCfs[1]} {testProcessedCfs[1]}
} }
}, op => op }, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) .Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>()); .WhenTypeIs<JToken>());
} }
[Test] [Test]
public void Custom_formats_missing_from_config_are_skipped() public void Custom_formats_missing_from_config_are_skipped()
{
var testProcessedCfs = new List<ProcessedCustomFormatData>
{ {
var testProcessedCfs = new List<ProcessedCustomFormatData> new("name1", "", new JObject()),
{ new("name2", "", new JObject())
new("name1", "", new JObject()), };
new("name2", "", new JObject())
};
var testConfig = new CustomFormatConfig[] var testConfig = new CustomFormatConfig[]
{
new()
{ {
new() Names = new List<string> {"name1"}
{ }
Names = new List<string> {"name1"} };
}
};
var processor = new ConfigStep(); var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig); processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.Should().BeEmpty(); processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData> processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{ {
new() CustomFormats = new List<ProcessedCustomFormatData>
{ {
CustomFormats = new List<ProcessedCustomFormatData> new("name1", "", new JObject())
{
new("name1", "", new JObject())
}
} }
}, op => op }
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) }, op => op
.WhenTypeIs<JToken>()); .Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
} .WhenTypeIs<JToken>());
}
[Test] [Test]
public void Custom_formats_missing_from_guide_are_added_to_not_in_guide_list() public void Custom_formats_missing_from_guide_are_added_to_not_in_guide_list()
{
var testProcessedCfs = new List<ProcessedCustomFormatData>
{ {
var testProcessedCfs = new List<ProcessedCustomFormatData> new("name1", "", new JObject()),
{ new("name2", "", new JObject())
new("name1", "", new JObject()), };
new("name2", "", new JObject())
};
var testConfig = new CustomFormatConfig[] var testConfig = new CustomFormatConfig[]
{
new()
{ {
new() Names = new List<string> {"name1", "name3"}
{ }
Names = new List<string> {"name1", "name3"} };
}
};
var processor = new ConfigStep(); var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig); processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.Should().BeEquivalentTo(new List<string> {"name3"}, op => op processor.CustomFormatsNotInGuide.Should().BeEquivalentTo(new List<string> {"name3"}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) .Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>()); .WhenTypeIs<JToken>());
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData> processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{ {
new() CustomFormats = new List<ProcessedCustomFormatData>
{ {
CustomFormats = new List<ProcessedCustomFormatData> new("name1", "", new JObject())
{
new("name1", "", new JObject())
}
} }
}, op => op }
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) }, op => op
.WhenTypeIs<JToken>()); .Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
} .WhenTypeIs<JToken>());
}
[Test] [Test]
public void Duplicate_config_name_and_id_are_ignored() public void Duplicate_config_name_and_id_are_ignored()
{
var testProcessedCfs = new List<ProcessedCustomFormatData>
{ {
var testProcessedCfs = new List<ProcessedCustomFormatData> new("name1", "id1", new JObject())
{ };
new("name1", "id1", new JObject())
};
var testConfig = new CustomFormatConfig[] var testConfig = new CustomFormatConfig[]
{
new()
{ {
new() Names = new List<string> {"name1"},
{ TrashIds = new List<string> {"id1"}
Names = new List<string> {"name1"}, }
TrashIds = new List<string> {"id1"} };
}
};
var processor = new ConfigStep(); var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig); processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.Should().BeEmpty(); processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData> processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{ {
new() CustomFormats = new List<ProcessedCustomFormatData> {testProcessedCfs[0]}
{ }
CustomFormats = new List<ProcessedCustomFormatData> {testProcessedCfs[0]} });
} }
});
}
[Test] [Test]
public void Duplicate_config_names_are_ignored() public void Duplicate_config_names_are_ignored()
{
var testProcessedCfs = new List<ProcessedCustomFormatData>
{ {
var testProcessedCfs = new List<ProcessedCustomFormatData> new("name1", "id1", new JObject())
{ };
new("name1", "id1", new JObject())
};
var testConfig = new CustomFormatConfig[] var testConfig = new CustomFormatConfig[]
{ {
new() {Names = new List<string> {"name1", "name1"}} new() {Names = new List<string> {"name1", "name1"}}
}; };
var processor = new ConfigStep(); var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig); processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.Should().BeEmpty(); processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData> processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{ {
new() CustomFormats = new List<ProcessedCustomFormatData> {testProcessedCfs[0]}
{ }
CustomFormats = new List<ProcessedCustomFormatData> {testProcessedCfs[0]} });
} }
});
}
[Test] [Test]
public void Find_custom_formats_by_name_and_trash_id() public void Find_custom_formats_by_name_and_trash_id()
{
var testProcessedCfs = new List<ProcessedCustomFormatData>
{ {
var testProcessedCfs = new List<ProcessedCustomFormatData> new("name1", "id1", JObject.FromObject(new {name = "name1"}))
{ {
new("name1", "id1", JObject.FromObject(new {name = "name1"})) Score = 100
{ },
Score = 100 new("name3", "id3", JObject.FromObject(new {name = "name3"})),
}, new("name4", "id4", new JObject())
new("name3", "id3", JObject.FromObject(new {name = "name3"})), };
new("name4", "id4", new JObject())
};
var testConfig = new CustomFormatConfig[] var testConfig = new CustomFormatConfig[]
{
new()
{ {
new() Names = new List<string> {"name1", "name3"},
TrashIds = new List<string> {"id1", "id4"},
QualityProfiles = new List<QualityProfileConfig>
{ {
Names = new List<string> {"name1", "name3"}, new() {Name = "profile1", Score = 50}
TrashIds = new List<string> {"id1", "id4"},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1", Score = 50}
}
} }
}; }
};
var processor = new ConfigStep(); var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig); processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.Should().BeEmpty(); processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData> processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{ {
new() new()
{ {
CustomFormats = testProcessedCfs, CustomFormats = testProcessedCfs,
QualityProfiles = testConfig[0].QualityProfiles QualityProfiles = testConfig[0].QualityProfiles
} }
}, op => op }, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) .Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>()); .WhenTypeIs<JToken>());
}
} }
} }

@ -11,379 +11,378 @@ using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache; using TrashLib.Radarr.CustomFormat.Models.Cache;
using TrashLib.Radarr.CustomFormat.Processors.GuideSteps; using TrashLib.Radarr.CustomFormat.Processors.GuideSteps;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.GuideSteps namespace TrashLib.Tests.Radarr.CustomFormat.Processors.GuideSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CustomFormatStepTest
{ {
[TestFixture] private class Context
[Parallelizable(ParallelScope.All)]
public class CustomFormatStepTest
{ {
private class Context public List<string> TestGuideData { get; } = new()
{ {
public List<string> TestGuideData { get; } = new() JsonConvert.SerializeObject(new
{ {
JsonConvert.SerializeObject(new trash_id = "id1",
{ name = "name1"
trash_id = "id1", }, Formatting.Indented),
name = "name1" JsonConvert.SerializeObject(new
}, Formatting.Indented),
JsonConvert.SerializeObject(new
{
trash_id = "id2",
name = "name2"
}, Formatting.Indented),
JsonConvert.SerializeObject(new
{
trash_id = "id3",
name = "name3"
}, Formatting.Indented)
};
}
[TestCase("name1", 0)]
[TestCase("naME1", 0)]
[TestCase("DifferentName", 1)]
public void Match_cf_in_guide_with_different_name_with_cache_using_same_name_in_config(string variableCfName,
int outdatedCount)
{
var testConfig = new List<CustomFormatConfig>
{ {
new() {Names = new List<string> {"name1"}} trash_id = "id2",
}; name = "name2"
}, Formatting.Indented),
var testCache = new CustomFormatCache JsonConvert.SerializeObject(new
{ {
TrashIdMappings = new Collection<TrashIdMapping> trash_id = "id3",
{ name = "name3"
new("id1", "name1") }, Formatting.Indented)
} };
}; }
var testGuideData = new List<string> [TestCase("name1", 0)]
{ [TestCase("naME1", 0)]
JsonConvert.SerializeObject(new [TestCase("DifferentName", 1)]
{ public void Match_cf_in_guide_with_different_name_with_cache_using_same_name_in_config(string variableCfName,
trash_id = "id1", int outdatedCount)
name = variableCfName {
}, Formatting.Indented) var testConfig = new List<CustomFormatConfig>
}; {
new() {Names = new List<string> {"name1"}}
var processor = new CustomFormatStep(); };
processor.Process(testGuideData, testConfig, testCache);
processor.DuplicatedCustomFormats.Should().BeEmpty();
processor.CustomFormatsWithOutdatedNames.Should().HaveCount(outdatedCount);
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
{
new(variableCfName, "id1", JObject.FromObject(new {name = variableCfName}))
{
CacheEntry = testCache.TrashIdMappings[0]
}
},
op => op.Using(new JsonEquivalencyStep()));
}
[Test] var testCache = new CustomFormatCache
public void Cache_entry_is_not_set_when_id_is_different()
{ {
var guideData = new List<string> TrashIdMappings = new Collection<TrashIdMapping>
{ {
@"{'name': 'name1', 'trash_id': 'id1'}" new("id1", "name1")
}; }
};
var testConfig = new List<CustomFormatConfig> var testGuideData = new List<string>
{
JsonConvert.SerializeObject(new
{ {
new() {Names = new List<string> {"name1"}} trash_id = "id1",
}; name = variableCfName
}, Formatting.Indented)
var testCache = new CustomFormatCache };
var processor = new CustomFormatStep();
processor.Process(testGuideData, testConfig, testCache);
processor.DuplicatedCustomFormats.Should().BeEmpty();
processor.CustomFormatsWithOutdatedNames.Should().HaveCount(outdatedCount);
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
{ {
TrashIdMappings = new Collection<TrashIdMapping> new(variableCfName, "id1", JObject.FromObject(new {name = variableCfName}))
{ {
new("id1000", "name1") CacheEntry = testCache.TrashIdMappings[0]
} }
}; },
op => op.Using(new JsonEquivalencyStep()));
}
var processor = new CustomFormatStep(); [Test]
processor.Process(guideData, testConfig, testCache); public void Cache_entry_is_not_set_when_id_is_different()
{
var guideData = new List<string>
{
@"{'name': 'name1', 'trash_id': 'id1'}"
};
processor.DuplicatedCustomFormats.Should().BeEmpty(); var testConfig = new List<CustomFormatConfig>
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); {
processor.DeletedCustomFormatsInCache.Count.Should().Be(1); new() {Names = new List<string> {"name1"}}
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData> };
{
new("name1", "id1", JObject.FromObject(new {name = "name1"}))
{
Score = null,
CacheEntry = null
}
},
op => op.Using(new JsonEquivalencyStep()));
}
[Test] var testCache = new CustomFormatCache
public void Cfs_not_in_config_are_skipped()
{ {
var ctx = new Context(); TrashIdMappings = new Collection<TrashIdMapping>
var testConfig = new List<CustomFormatConfig>
{ {
new() {Names = new List<string> {"name1", "name3"}} new("id1000", "name1")
}; }
};
var processor = new CustomFormatStep(); var processor = new CustomFormatStep();
processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache()); processor.Process(guideData, testConfig, testCache);
processor.DuplicatedCustomFormats.Should().BeEmpty(); processor.DuplicatedCustomFormats.Should().BeEmpty();
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
processor.DeletedCustomFormatsInCache.Should().BeEmpty(); processor.DeletedCustomFormatsInCache.Count.Should().Be(1);
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData> processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
{
new("name1", "id1", JObject.FromObject(new {name = "name1"}))
{ {
new("name1", "id1", JObject.FromObject(new {name = "name1"})) {Score = null}, Score = null,
new("name3", "id3", JObject.FromObject(new {name = "name3"})) {Score = null} CacheEntry = null
}, }
op => op.Using(new JsonEquivalencyStep())); },
} op => op.Using(new JsonEquivalencyStep()));
}
[Test] [Test]
public void Config_cfs_in_different_sections_are_processed() public void Cfs_not_in_config_are_skipped()
{
var ctx = new Context();
var testConfig = new List<CustomFormatConfig>
{ {
var ctx = new Context(); new() {Names = new List<string> {"name1", "name3"}}
var testConfig = new List<CustomFormatConfig> };
{
new() {Names = new List<string> {"name1", "name3"}},
new() {Names = new List<string> {"name2"}}
};
var processor = new CustomFormatStep(); var processor = new CustomFormatStep();
processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache()); processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache());
processor.DuplicatedCustomFormats.Should().BeEmpty(); processor.DuplicatedCustomFormats.Should().BeEmpty();
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
processor.DeletedCustomFormatsInCache.Should().BeEmpty(); processor.DeletedCustomFormatsInCache.Should().BeEmpty();
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData> processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
{ {
new("name1", "id1", JObject.FromObject(new {name = "name1"})) {Score = null}, new("name1", "id1", JObject.FromObject(new {name = "name1"})) {Score = null},
new("name2", "id2", JObject.FromObject(new {name = "name2"})) {Score = null}, new("name3", "id3", JObject.FromObject(new {name = "name3"})) {Score = null}
new("name3", "id3", JObject.FromObject(new {name = "name3"})) {Score = null} },
}, op => op.Using(new JsonEquivalencyStep()));
op => op.Using(new JsonEquivalencyStep())); }
}
[Test] [Test]
public void Custom_format_is_deleted_if_in_config_and_cache_but_not_in_guide() public void Config_cfs_in_different_sections_are_processed()
{
var ctx = new Context();
var testConfig = new List<CustomFormatConfig>
{ {
var guideData = new List<string> new() {Names = new List<string> {"name1", "name3"}},
{ new() {Names = new List<string> {"name2"}}
@"{'name': 'name1', 'trash_id': 'id1'}" };
};
var testConfig = new List<CustomFormatConfig> var processor = new CustomFormatStep();
{ processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache());
new() {Names = new List<string> {"name1"}}
};
var testCache = new CustomFormatCache processor.DuplicatedCustomFormats.Should().BeEmpty();
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
{ {
TrashIdMappings = new Collection<TrashIdMapping> {new("id1000", "name1")} new("name1", "id1", JObject.FromObject(new {name = "name1"})) {Score = null},
}; new("name2", "id2", JObject.FromObject(new {name = "name2"})) {Score = null},
new("name3", "id3", JObject.FromObject(new {name = "name3"})) {Score = null}
},
op => op.Using(new JsonEquivalencyStep()));
}
var processor = new CustomFormatStep(); [Test]
processor.Process(guideData, testConfig, testCache); public void Custom_format_is_deleted_if_in_config_and_cache_but_not_in_guide()
{
var guideData = new List<string>
{
@"{'name': 'name1', 'trash_id': 'id1'}"
};
processor.DuplicatedCustomFormats.Should().BeEmpty(); var testConfig = new List<CustomFormatConfig>
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); {
processor.DeletedCustomFormatsInCache.Should() new() {Names = new List<string> {"name1"}}
.BeEquivalentTo(new[] {new TrashIdMapping("id1000", "name1")}); };
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
{
new("name1", "id1", JObject.Parse(@"{'name': 'name1'}"))
},
op => op.Using(new JsonEquivalencyStep()));
}
[Test] var testCache = new CustomFormatCache
public void Custom_format_is_deleted_if_not_in_config_but_in_cache_and_in_guide()
{ {
var cache = new CustomFormatCache TrashIdMappings = new Collection<TrashIdMapping> {new("id1000", "name1")}
{ };
TrashIdMappings = new Collection<TrashIdMapping> {new("id1", "3D", 9)}
};
var guideCfs = new List<string> var processor = new CustomFormatStep();
{ processor.Process(guideData, testConfig, testCache);
"{'name': '3D', 'trash_id': 'id1'}"
};
var processor = new CustomFormatStep(); processor.DuplicatedCustomFormats.Should().BeEmpty();
processor.Process(guideCfs, Array.Empty<CustomFormatConfig>(), cache); processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
processor.DeletedCustomFormatsInCache.Should()
.BeEquivalentTo(new[] {new TrashIdMapping("id1000", "name1")});
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
{
new("name1", "id1", JObject.Parse(@"{'name': 'name1'}"))
},
op => op.Using(new JsonEquivalencyStep()));
}
processor.DuplicatedCustomFormats.Should().BeEmpty(); [Test]
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); public void Custom_format_is_deleted_if_not_in_config_but_in_cache_and_in_guide()
processor.DeletedCustomFormatsInCache.Should().BeEquivalentTo(new[] {cache.TrashIdMappings[0]}); {
processor.ProcessedCustomFormats.Should().BeEmpty(); var cache = new CustomFormatCache
} {
TrashIdMappings = new Collection<TrashIdMapping> {new("id1", "3D", 9)}
};
[Test] var guideCfs = new List<string>
public void Custom_format_name_in_cache_is_updated_if_renamed_in_guide_and_config()
{ {
var guideData = new List<string> "{'name': '3D', 'trash_id': 'id1'}"
{ };
@"{'name': 'name2', 'trash_id': 'id1'}"
};
var testConfig = new List<CustomFormatConfig> var processor = new CustomFormatStep();
{ processor.Process(guideCfs, Array.Empty<CustomFormatConfig>(), cache);
new() {Names = new List<string> {"name2"}}
};
var testCache = new CustomFormatCache processor.DuplicatedCustomFormats.Should().BeEmpty();
{ processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
TrashIdMappings = new Collection<TrashIdMapping> {new("id1", "name1")} processor.DeletedCustomFormatsInCache.Should().BeEquivalentTo(new[] {cache.TrashIdMappings[0]});
}; processor.ProcessedCustomFormats.Should().BeEmpty();
}
var processor = new CustomFormatStep();
processor.Process(guideData, testConfig, testCache); [Test]
public void Custom_format_name_in_cache_is_updated_if_renamed_in_guide_and_config()
processor.DuplicatedCustomFormats.Should().BeEmpty(); {
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); var guideData = new List<string>
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
processor.ProcessedCustomFormats.Should()
.ContainSingle().Which.CacheEntry.Should()
.BeEquivalentTo(new TrashIdMapping("id1", "name2"));
}
[Test]
public void Duplicates_are_recorded_and_removed_from_processed_custom_formats_list()
{ {
var guideData = new List<string> @"{'name': 'name2', 'trash_id': 'id1'}"
{ };
@"{'name': 'name1', 'trash_id': 'id1'}",
@"{'name': 'name1', 'trash_id': 'id2'}"
};
var testConfig = new List<CustomFormatConfig> var testConfig = new List<CustomFormatConfig>
{ {
new() {Names = new List<string> {"name1"}} new() {Names = new List<string> {"name2"}}
}; };
var processor = new CustomFormatStep(); var testCache = new CustomFormatCache
processor.Process(guideData, testConfig, null); {
TrashIdMappings = new Collection<TrashIdMapping> {new("id1", "name1")}
};
var processor = new CustomFormatStep();
processor.Process(guideData, testConfig, testCache);
processor.DuplicatedCustomFormats.Should().BeEmpty();
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
processor.ProcessedCustomFormats.Should()
.ContainSingle().Which.CacheEntry.Should()
.BeEquivalentTo(new TrashIdMapping("id1", "name2"));
}
//Dictionary<string, List<ProcessedCustomFormatData>> [Test]
processor.DuplicatedCustomFormats.Should() public void Duplicates_are_recorded_and_removed_from_processed_custom_formats_list()
.ContainKey("name1").WhoseValue.Should() {
.BeEquivalentTo(new List<ProcessedCustomFormatData> var guideData = new List<string>
{
new("name1", "id1", JObject.Parse(@"{'name': 'name1'}")),
new("name1", "id2", JObject.Parse(@"{'name': 'name1'}"))
});
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
processor.ProcessedCustomFormats.Should().BeEmpty();
}
[Test]
public void Match_cf_names_regardless_of_case_in_config()
{ {
var ctx = new Context(); @"{'name': 'name1', 'trash_id': 'id1'}",
var testConfig = new List<CustomFormatConfig> @"{'name': 'name1', 'trash_id': 'id2'}"
{ };
new() {Names = new List<string> {"name1", "NAME1"}}
};
var processor = new CustomFormatStep(); var testConfig = new List<CustomFormatConfig>
processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache()); {
new() {Names = new List<string> {"name1"}}
};
processor.DuplicatedCustomFormats.Should().BeEmpty(); var processor = new CustomFormatStep();
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); processor.Process(guideData, testConfig, null);
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
{
new("name1", "id1", JObject.FromObject(new {name = "name1"}))
},
op => op.Using(new JsonEquivalencyStep()));
}
[Test] //Dictionary<string, List<ProcessedCustomFormatData>>
public void Match_custom_format_using_trash_id() processor.DuplicatedCustomFormats.Should()
{ .ContainKey("name1").WhoseValue.Should()
var guideData = new List<string> .BeEquivalentTo(new List<ProcessedCustomFormatData>
{ {
@"{'name': 'name1', 'trash_id': 'id1'}", new("name1", "id1", JObject.Parse(@"{'name': 'name1'}")),
@"{'name': 'name2', 'trash_id': 'id2'}" new("name1", "id2", JObject.Parse(@"{'name': 'name1'}"))
}; });
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
processor.ProcessedCustomFormats.Should().BeEmpty();
}
var testConfig = new List<CustomFormatConfig> [Test]
{ public void Match_cf_names_regardless_of_case_in_config()
new() {TrashIds = new List<string> {"id2"}} {
}; var ctx = new Context();
var testConfig = new List<CustomFormatConfig>
{
new() {Names = new List<string> {"name1", "NAME1"}}
};
var processor = new CustomFormatStep(); var processor = new CustomFormatStep();
processor.Process(guideData, testConfig, null); processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache());
processor.DuplicatedCustomFormats.Should().BeEmpty(); processor.DuplicatedCustomFormats.Should().BeEmpty();
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
processor.DeletedCustomFormatsInCache.Should().BeEmpty(); processor.DeletedCustomFormatsInCache.Should().BeEmpty();
processor.ProcessedCustomFormats.Should() processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
.BeEquivalentTo(new List<ProcessedCustomFormatData> {
{ new("name1", "id1", JObject.FromObject(new {name = "name1"}))
new("name2", "id2", JObject.FromObject(new {name = "name2"})) },
}); op => op.Using(new JsonEquivalencyStep()));
} }
[Test] [Test]
public void Non_existent_cfs_in_config_are_skipped() public void Match_custom_format_using_trash_id()
{
var guideData = new List<string>
{ {
var ctx = new Context(); @"{'name': 'name1', 'trash_id': 'id1'}",
var testConfig = new List<CustomFormatConfig> @"{'name': 'name2', 'trash_id': 'id2'}"
};
var testConfig = new List<CustomFormatConfig>
{
new() {TrashIds = new List<string> {"id2"}}
};
var processor = new CustomFormatStep();
processor.Process(guideData, testConfig, null);
processor.DuplicatedCustomFormats.Should().BeEmpty();
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
processor.ProcessedCustomFormats.Should()
.BeEquivalentTo(new List<ProcessedCustomFormatData>
{ {
new() {Names = new List<string> {"doesnt_exist"}} new("name2", "id2", JObject.FromObject(new {name = "name2"}))
}; });
}
[Test]
public void Non_existent_cfs_in_config_are_skipped()
{
var ctx = new Context();
var testConfig = new List<CustomFormatConfig>
{
new() {Names = new List<string> {"doesnt_exist"}}
};
var processor = new CustomFormatStep(); var processor = new CustomFormatStep();
processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache()); processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache());
processor.DuplicatedCustomFormats.Should().BeEmpty(); processor.DuplicatedCustomFormats.Should().BeEmpty();
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
processor.DeletedCustomFormatsInCache.Should().BeEmpty(); processor.DeletedCustomFormatsInCache.Should().BeEmpty();
processor.ProcessedCustomFormats.Should().BeEmpty(); processor.ProcessedCustomFormats.Should().BeEmpty();
} }
[Test] [Test]
public void Score_from_json_takes_precedence_over_score_from_guide() public void Score_from_json_takes_precedence_over_score_from_guide()
{
var guideData = new List<string>
{ {
var guideData = new List<string> @"{'name': 'name1', 'trash_id': 'id1', 'trash_score': 100}"
{ };
@"{'name': 'name1', 'trash_id': 'id1', 'trash_score': 100}"
};
var testConfig = new List<CustomFormatConfig> var testConfig = new List<CustomFormatConfig>
{
new()
{ {
new() Names = new List<string> {"name1"},
QualityProfiles = new List<QualityProfileConfig>
{ {
Names = new List<string> {"name1"}, new() {Name = "profile", Score = 200}
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile", Score = 200}
}
} }
}; }
};
var processor = new CustomFormatStep(); var processor = new CustomFormatStep();
processor.Process(guideData, testConfig, null); processor.Process(guideData, testConfig, null);
processor.DuplicatedCustomFormats.Should().BeEmpty(); processor.DuplicatedCustomFormats.Should().BeEmpty();
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
processor.DeletedCustomFormatsInCache.Should().BeEmpty(); processor.DeletedCustomFormatsInCache.Should().BeEmpty();
processor.ProcessedCustomFormats.Should() processor.ProcessedCustomFormats.Should()
.BeEquivalentTo(new List<ProcessedCustomFormatData> .BeEquivalentTo(new List<ProcessedCustomFormatData>
{
new("name1", "id1", JObject.FromObject(new {name = "name1"}))
{ {
new("name1", "id1", JObject.FromObject(new {name = "name1"})) Score = 100
{ }
Score = 100 },
} op => op.Using(new JsonEquivalencyStep()));
},
op => op.Using(new JsonEquivalencyStep()));
}
} }
} }

@ -8,127 +8,126 @@ using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Models; using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Processors.GuideSteps; using TrashLib.Radarr.CustomFormat.Processors.GuideSteps;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.GuideSteps namespace TrashLib.Tests.Radarr.CustomFormat.Processors.GuideSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class QualityProfileStepTest
{ {
[TestFixture] [Test]
[Parallelizable(ParallelScope.All)] public void No_score_used_if_no_score_in_config_or_guide()
public class QualityProfileStepTest
{ {
[Test] var testConfigData = new List<ProcessedConfigData>
public void No_score_used_if_no_score_in_config_or_guide()
{ {
var testConfigData = new List<ProcessedConfigData> new()
{ {
new() CustomFormats = new List<ProcessedCustomFormatData>
{
new("name1", "id1", new JObject()) {Score = null}
},
QualityProfiles = new List<QualityProfileConfig>
{ {
CustomFormats = new List<ProcessedCustomFormatData> new() {Name = "profile1"}
{
new("name1", "id1", new JObject()) {Score = null}
},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1"}
}
} }
}; }
};
var processor = new QualityProfileStep(); var processor = new QualityProfileStep();
processor.Process(testConfigData); processor.Process(testConfigData);
processor.ProfileScores.Should().BeEmpty(); processor.ProfileScores.Should().BeEmpty();
processor.CustomFormatsWithoutScore.Should().Equal(("name1", "id1", "profile1")); processor.CustomFormatsWithoutScore.Should().Equal(("name1", "id1", "profile1"));
} }
[Test] [Test]
public void Overwrite_score_from_guide_if_config_defines_score() public void Overwrite_score_from_guide_if_config_defines_score()
{
var testConfigData = new List<ProcessedConfigData>
{ {
var testConfigData = new List<ProcessedConfigData> new()
{ {
new() CustomFormats = new List<ProcessedCustomFormatData>
{ {
CustomFormats = new List<ProcessedCustomFormatData> new("", "id1", new JObject()) {Score = 100}
{ },
new("", "id1", new JObject()) {Score = 100} QualityProfiles = new List<QualityProfileConfig>
}, {
QualityProfiles = new List<QualityProfileConfig> new() {Name = "profile1", Score = 50}
{
new() {Name = "profile1", Score = 50}
}
} }
}; }
};
var processor = new QualityProfileStep(); var processor = new QualityProfileStep();
processor.Process(testConfigData); processor.Process(testConfigData);
processor.ProfileScores.Should() processor.ProfileScores.Should()
.ContainKey("profile1").WhoseValue.Should() .ContainKey("profile1").WhoseValue.Should()
.BeEquivalentTo( .BeEquivalentTo(
CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats.First(), 50))); CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats.First(), 50)));
processor.CustomFormatsWithoutScore.Should().BeEmpty(); processor.CustomFormatsWithoutScore.Should().BeEmpty();
} }
[Test] [Test]
public void Use_guide_score_if_no_score_in_config() public void Use_guide_score_if_no_score_in_config()
{
var testConfigData = new List<ProcessedConfigData>
{ {
var testConfigData = new List<ProcessedConfigData> new()
{ {
new() CustomFormats = new List<ProcessedCustomFormatData>
{ {
CustomFormats = new List<ProcessedCustomFormatData> new("", "id1", new JObject()) {Score = 100}
{ },
new("", "id1", new JObject()) {Score = 100} QualityProfiles = new List<QualityProfileConfig>
}, {
QualityProfiles = new List<QualityProfileConfig> new() {Name = "profile1"},
{ new() {Name = "profile2", Score = null}
new() {Name = "profile1"},
new() {Name = "profile2", Score = null}
}
} }
}; }
};
var processor = new QualityProfileStep(); var processor = new QualityProfileStep();
processor.Process(testConfigData); processor.Process(testConfigData);
var expectedScoreEntries = var expectedScoreEntries =
CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats.First(), 100)); CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats.First(), 100));
processor.ProfileScores.Should().BeEquivalentTo( processor.ProfileScores.Should().BeEquivalentTo(
new Dictionary<string, QualityProfileCustomFormatScoreMapping> new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{ {
{"profile1", expectedScoreEntries}, {"profile1", expectedScoreEntries},
{"profile2", expectedScoreEntries} {"profile2", expectedScoreEntries}
}); });
processor.CustomFormatsWithoutScore.Should().BeEmpty(); processor.CustomFormatsWithoutScore.Should().BeEmpty();
} }
[Test] [Test]
public void Zero_score_is_not_ignored() public void Zero_score_is_not_ignored()
{
var testConfigData = new List<ProcessedConfigData>
{ {
var testConfigData = new List<ProcessedConfigData> new()
{ {
new() CustomFormats = new List<ProcessedCustomFormatData>
{
new("name1", "id1", new JObject()) {Score = 0}
},
QualityProfiles = new List<QualityProfileConfig>
{ {
CustomFormats = new List<ProcessedCustomFormatData> new() {Name = "profile1"}
{
new("name1", "id1", new JObject()) {Score = 0}
},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1"}
}
} }
}; }
};
var processor = new QualityProfileStep(); var processor = new QualityProfileStep();
processor.Process(testConfigData); processor.Process(testConfigData);
processor.ProfileScores.Should() processor.ProfileScores.Should()
.ContainKey("profile1").WhoseValue.Should() .ContainKey("profile1").WhoseValue.Should()
.BeEquivalentTo(CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats.First(), 0))); .BeEquivalentTo(CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats.First(), 0)));
processor.CustomFormatsWithoutScore.Should().BeEmpty(); processor.CustomFormatsWithoutScore.Should().BeEmpty();
}
} }
} }

@ -11,75 +11,74 @@ using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache; using TrashLib.Radarr.CustomFormat.Models.Cache;
using TrashLib.Radarr.CustomFormat.Processors; using TrashLib.Radarr.CustomFormat.Processors;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors namespace TrashLib.Tests.Radarr.CustomFormat.Processors;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class PersistenceProcessorTest
{ {
[TestFixture] [Test]
[Parallelizable(ParallelScope.All)] public void Custom_formats_are_deleted_if_deletion_option_is_enabled_in_config()
public class PersistenceProcessorTest {
var steps = Substitute.For<IPersistenceProcessorSteps>();
var cfApi = Substitute.For<ICustomFormatService>();
var qpApi = Substitute.For<IQualityProfileService>();
var configProvider = Substitute.For<IConfigurationProvider>();
configProvider.ActiveConfiguration = new RadarrConfiguration {DeleteOldCustomFormats = true};
var guideCfs = Array.Empty<ProcessedCustomFormatData>();
var deletedCfsInCache = new Collection<TrashIdMapping>();
var profileScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>();
var processor = new PersistenceProcessor(cfApi, qpApi, configProvider, () => steps);
processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
steps.JsonTransactionStep.Received().RecordDeletions(Arg.Is(deletedCfsInCache), Arg.Any<List<JObject>>());
}
[Test]
public void Custom_formats_are_not_deleted_if_deletion_option_is_disabled_in_config()
{
var steps = Substitute.For<IPersistenceProcessorSteps>();
var cfApi = Substitute.For<ICustomFormatService>();
var qpApi = Substitute.For<IQualityProfileService>();
var configProvider = Substitute.For<IConfigurationProvider>();
configProvider.ActiveConfiguration = new RadarrConfiguration {DeleteOldCustomFormats = false};
var guideCfs = Array.Empty<ProcessedCustomFormatData>();
var deletedCfsInCache = Array.Empty<TrashIdMapping>();
var profileScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>();
var processor = new PersistenceProcessor(cfApi, qpApi, configProvider, () => steps);
processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
steps.JsonTransactionStep.DidNotReceive()
.RecordDeletions(Arg.Any<IEnumerable<TrashIdMapping>>(), Arg.Any<List<JObject>>());
}
[Test]
public void Different_active_configuration_is_properly_used()
{ {
[Test] var steps = Substitute.For<IPersistenceProcessorSteps>();
public void Custom_formats_are_deleted_if_deletion_option_is_enabled_in_config() var cfApi = Substitute.For<ICustomFormatService>();
{ var qpApi = Substitute.For<IQualityProfileService>();
var steps = Substitute.For<IPersistenceProcessorSteps>(); var configProvider = Substitute.For<IConfigurationProvider>();
var cfApi = Substitute.For<ICustomFormatService>();
var qpApi = Substitute.For<IQualityProfileService>(); var guideCfs = Array.Empty<ProcessedCustomFormatData>();
var deletedCfsInCache = Array.Empty<TrashIdMapping>();
var configProvider = Substitute.For<IConfigurationProvider>(); var profileScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>();
configProvider.ActiveConfiguration = new RadarrConfiguration {DeleteOldCustomFormats = true};
var processor = new PersistenceProcessor(cfApi, qpApi, configProvider, () => steps);
var guideCfs = Array.Empty<ProcessedCustomFormatData>();
var deletedCfsInCache = new Collection<TrashIdMapping>(); configProvider.ActiveConfiguration = new RadarrConfiguration {DeleteOldCustomFormats = false};
var profileScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>(); processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
var processor = new PersistenceProcessor(cfApi, qpApi, configProvider, () => steps); configProvider.ActiveConfiguration = new RadarrConfiguration {DeleteOldCustomFormats = true};
processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores); processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
steps.JsonTransactionStep.Received().RecordDeletions(Arg.Is(deletedCfsInCache), Arg.Any<List<JObject>>()); steps.JsonTransactionStep.Received(1)
} .RecordDeletions(Arg.Any<IEnumerable<TrashIdMapping>>(), Arg.Any<List<JObject>>());
[Test]
public void Custom_formats_are_not_deleted_if_deletion_option_is_disabled_in_config()
{
var steps = Substitute.For<IPersistenceProcessorSteps>();
var cfApi = Substitute.For<ICustomFormatService>();
var qpApi = Substitute.For<IQualityProfileService>();
var configProvider = Substitute.For<IConfigurationProvider>();
configProvider.ActiveConfiguration = new RadarrConfiguration {DeleteOldCustomFormats = false};
var guideCfs = Array.Empty<ProcessedCustomFormatData>();
var deletedCfsInCache = Array.Empty<TrashIdMapping>();
var profileScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>();
var processor = new PersistenceProcessor(cfApi, qpApi, configProvider, () => steps);
processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
steps.JsonTransactionStep.DidNotReceive()
.RecordDeletions(Arg.Any<IEnumerable<TrashIdMapping>>(), Arg.Any<List<JObject>>());
}
[Test]
public void Different_active_configuration_is_properly_used()
{
var steps = Substitute.For<IPersistenceProcessorSteps>();
var cfApi = Substitute.For<ICustomFormatService>();
var qpApi = Substitute.For<IQualityProfileService>();
var configProvider = Substitute.For<IConfigurationProvider>();
var guideCfs = Array.Empty<ProcessedCustomFormatData>();
var deletedCfsInCache = Array.Empty<TrashIdMapping>();
var profileScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>();
var processor = new PersistenceProcessor(cfApi, qpApi, configProvider, () => steps);
configProvider.ActiveConfiguration = new RadarrConfiguration {DeleteOldCustomFormats = false};
processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
configProvider.ActiveConfiguration = new RadarrConfiguration {DeleteOldCustomFormats = true};
processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
steps.JsonTransactionStep.Received(1)
.RecordDeletions(Arg.Any<IEnumerable<TrashIdMapping>>(), Arg.Any<List<JObject>>());
}
} }
} }

@ -8,40 +8,39 @@ using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache; using TrashLib.Radarr.CustomFormat.Models.Cache;
using TrashLib.Radarr.CustomFormat.Processors.PersistenceSteps; using TrashLib.Radarr.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CustomFormatApiPersistenceStepTest
{ {
[TestFixture] private static ProcessedCustomFormatData QuickMakeCf(string cfName, string trashId, int cfId)
[Parallelizable(ParallelScope.All)]
public class CustomFormatApiPersistenceStepTest
{ {
private static ProcessedCustomFormatData QuickMakeCf(string cfName, string trashId, int cfId) return new ProcessedCustomFormatData(cfName, trashId, new JObject())
{ {
return new ProcessedCustomFormatData(cfName, trashId, new JObject()) CacheEntry = new TrashIdMapping(trashId, cfName) {CustomFormatId = cfId}
{ };
CacheEntry = new TrashIdMapping(trashId, cfName) {CustomFormatId = cfId} }
};
}
[Test] [Test]
public async Task All_api_operations_behave_normally() public async Task All_api_operations_behave_normally()
{ {
var transactions = new CustomFormatTransactionData(); var transactions = new CustomFormatTransactionData();
transactions.NewCustomFormats.Add(QuickMakeCf("cfname1", "trashid1", 1)); transactions.NewCustomFormats.Add(QuickMakeCf("cfname1", "trashid1", 1));
transactions.UpdatedCustomFormats.Add(QuickMakeCf("cfname2", "trashid2", 2)); transactions.UpdatedCustomFormats.Add(QuickMakeCf("cfname2", "trashid2", 2));
transactions.UnchangedCustomFormats.Add(QuickMakeCf("cfname3", "trashid3", 3)); transactions.UnchangedCustomFormats.Add(QuickMakeCf("cfname3", "trashid3", 3));
transactions.DeletedCustomFormatIds.Add(new TrashIdMapping("trashid4", "cfname4") {CustomFormatId = 4}); transactions.DeletedCustomFormatIds.Add(new TrashIdMapping("trashid4", "cfname4") {CustomFormatId = 4});
var api = Substitute.For<ICustomFormatService>(); var api = Substitute.For<ICustomFormatService>();
var processor = new CustomFormatApiPersistenceStep(); var processor = new CustomFormatApiPersistenceStep();
await processor.Process(api, transactions); await processor.Process(api, transactions);
Received.InOrder(() => Received.InOrder(() =>
{ {
api.CreateCustomFormat(transactions.NewCustomFormats.First()); api.CreateCustomFormat(transactions.NewCustomFormats.First());
api.UpdateCustomFormat(transactions.UpdatedCustomFormats.First()); api.UpdateCustomFormat(transactions.UpdatedCustomFormats.First());
api.DeleteCustomFormat(4); api.DeleteCustomFormat(4);
}); });
}
} }
} }

@ -37,18 +37,18 @@ using TrashLib.Radarr.CustomFormat.Processors.PersistenceSteps;
} }
*/ */
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class JsonTransactionStepTest
{ {
[TestFixture] [TestCase(1, "cf2")]
[Parallelizable(ParallelScope.All)] [TestCase(2, "cf1")]
public class JsonTransactionStepTest [TestCase(null, "cf1")]
public void Updates_using_combination_of_id_and_name(int? id, string guideCfName)
{ {
[TestCase(1, "cf2")] const string radarrCfData = @"{
[TestCase(2, "cf1")]
[TestCase(null, "cf1")]
public void Updates_using_combination_of_id_and_name(int? id, string guideCfName)
{
const string radarrCfData = @"{
'id': 1, 'id': 1,
'name': 'cf1', 'name': 'cf1',
'specifications': [{ 'specifications': [{
@ -59,7 +59,7 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}] }]
}] }]
}"; }";
var guideCfData = JObject.Parse(@"{ var guideCfData = JObject.Parse(@"{
'name': 'cf1', 'name': 'cf1',
'specifications': [{ 'specifications': [{
'name': 'spec1', 'name': 'spec1',
@ -69,21 +69,21 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
} }
}] }]
}"); }");
var cacheEntry = id != null ? new TrashIdMapping("", "") {CustomFormatId = id.Value} : null; var cacheEntry = id != null ? new TrashIdMapping("", "") {CustomFormatId = id.Value} : null;
var guideCfs = new List<ProcessedCustomFormatData> var guideCfs = new List<ProcessedCustomFormatData>
{ {
new(guideCfName, "", guideCfData) {CacheEntry = cacheEntry} new(guideCfName, "", guideCfData) {CacheEntry = cacheEntry}
}; };
var processor = new JsonTransactionStep(); var processor = new JsonTransactionStep();
processor.Process(guideCfs, new[] {JObject.Parse(radarrCfData)}); processor.Process(guideCfs, new[] {JObject.Parse(radarrCfData)});
var expectedTransactions = new CustomFormatTransactionData(); var expectedTransactions = new CustomFormatTransactionData();
expectedTransactions.UpdatedCustomFormats.Add(guideCfs[0]); expectedTransactions.UpdatedCustomFormats.Add(guideCfs[0]);
processor.Transactions.Should().BeEquivalentTo(expectedTransactions); processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
const string expectedJsonData = @"{ const string expectedJsonData = @"{
'id': 1, 'id': 1,
'name': 'cf1', 'name': 'cf1',
'specifications': [{ 'specifications': [{
@ -95,14 +95,14 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}] }]
}] }]
}"; }";
processor.Transactions.UpdatedCustomFormats.First().Json.Should() processor.Transactions.UpdatedCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJsonData), op => op.Using(new JsonEquivalencyStep())); .BeEquivalentTo(JObject.Parse(expectedJsonData), op => op.Using(new JsonEquivalencyStep()));
} }
[Test] [Test]
public void Combination_of_create_update_and_unchanged_and_verify_proper_json_merging() public void Combination_of_create_update_and_unchanged_and_verify_proper_json_merging()
{ {
const string radarrCfData = @"[{ const string radarrCfData = @"[{
'id': 1, 'id': 1,
'name': 'user_defined', 'name': 'user_defined',
'specifications': [{ 'specifications': [{
@ -137,7 +137,7 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}] }]
}] }]
}]"; }]";
var guideCfData = JsonConvert.DeserializeObject<List<JObject>>(@"[{ var guideCfData = JsonConvert.DeserializeObject<List<JObject>>(@"[{
'name': 'created', 'name': 'created',
'specifications': [{ 'specifications': [{
'name': 'spec5', 'name': 'spec5',
@ -172,23 +172,23 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}] }]
}]"); }]");
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData); var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData);
var guideCfs = new List<ProcessedCustomFormatData> var guideCfs = new List<ProcessedCustomFormatData>
{
new("created", "", guideCfData![0]),
new("updated_different_name", "", guideCfData[1])
{ {
new("created", "", guideCfData![0]), CacheEntry = new TrashIdMapping("", "", 2)
new("updated_different_name", "", guideCfData[1]) },
{ new("no_change", "", guideCfData[2])
CacheEntry = new TrashIdMapping("", "", 2) };
},
new("no_change", "", guideCfData[2])
};
var processor = new JsonTransactionStep(); var processor = new JsonTransactionStep();
processor.Process(guideCfs, radarrCfs!); processor.Process(guideCfs, radarrCfs!);
var expectedJson = new[] var expectedJson = new[]
{ {
@"{ @"{
'name': 'created', 'name': 'created',
'specifications': [{ 'specifications': [{
'name': 'spec5', 'name': 'spec5',
@ -198,7 +198,7 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}] }]
}] }]
}", }",
@"{ @"{
'id': 2, 'id': 2,
'name': 'updated_different_name', 'name': 'updated_different_name',
'specifications': [{ 'specifications': [{
@ -219,7 +219,7 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}] }]
}] }]
}", }",
@"{ @"{
'id': 3, 'id': 3,
'name': 'no_change', 'name': 'no_change',
'specifications': [{ 'specifications': [{
@ -231,28 +231,28 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}] }]
}] }]
}" }"
}; };
var expectedTransactions = new CustomFormatTransactionData(); var expectedTransactions = new CustomFormatTransactionData();
expectedTransactions.NewCustomFormats.Add(guideCfs[0]); expectedTransactions.NewCustomFormats.Add(guideCfs[0]);
expectedTransactions.UpdatedCustomFormats.Add(guideCfs[1]); expectedTransactions.UpdatedCustomFormats.Add(guideCfs[1]);
expectedTransactions.UnchangedCustomFormats.Add(guideCfs[2]); expectedTransactions.UnchangedCustomFormats.Add(guideCfs[2]);
processor.Transactions.Should().BeEquivalentTo(expectedTransactions); processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
processor.Transactions.NewCustomFormats.First().Json.Should() processor.Transactions.NewCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJson[0]), op => op.Using(new JsonEquivalencyStep())); .BeEquivalentTo(JObject.Parse(expectedJson[0]), op => op.Using(new JsonEquivalencyStep()));
processor.Transactions.UpdatedCustomFormats.First().Json.Should() processor.Transactions.UpdatedCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJson[1]), op => op.Using(new JsonEquivalencyStep())); .BeEquivalentTo(JObject.Parse(expectedJson[1]), op => op.Using(new JsonEquivalencyStep()));
processor.Transactions.UnchangedCustomFormats.First().Json.Should() processor.Transactions.UnchangedCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJson[2]), op => op.Using(new JsonEquivalencyStep())); .BeEquivalentTo(JObject.Parse(expectedJson[2]), op => op.Using(new JsonEquivalencyStep()));
} }
[Test] [Test]
public void Deletes_happen_before_updates() public void Deletes_happen_before_updates()
{ {
const string radarrCfData = @"[{ const string radarrCfData = @"[{
'id': 1, 'id': 1,
'name': 'updated', 'name': 'updated',
'specifications': [{ 'specifications': [{
@ -275,7 +275,7 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}] }]
}] }]
}]"; }]";
var guideCfData = JObject.Parse(@"{ var guideCfData = JObject.Parse(@"{
'name': 'updated', 'name': 'updated',
'specifications': [{ 'specifications': [{
'name': 'spec2', 'name': 'spec2',
@ -284,23 +284,23 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
} }
}] }]
}"); }");
var deletedCfsInCache = new List<TrashIdMapping> var deletedCfsInCache = new List<TrashIdMapping>
{ {
new("", "") {CustomFormatId = 2} new("", "") {CustomFormatId = 2}
}; };
var guideCfs = new List<ProcessedCustomFormatData> var guideCfs = new List<ProcessedCustomFormatData>
{ {
new("updated", "", guideCfData) {CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 1}} new("updated", "", guideCfData) {CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 1}}
}; };
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData); var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData);
var processor = new JsonTransactionStep(); var processor = new JsonTransactionStep();
processor.Process(guideCfs, radarrCfs!); processor.Process(guideCfs, radarrCfs!);
processor.RecordDeletions(deletedCfsInCache, radarrCfs!); processor.RecordDeletions(deletedCfsInCache, radarrCfs!);
var expectedJson = @"{ var expectedJson = @"{
'id': 1, 'id': 1,
'name': 'updated', 'name': 'updated',
'specifications': [{ 'specifications': [{
@ -311,19 +311,19 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}] }]
}] }]
}"; }";
var expectedTransactions = new CustomFormatTransactionData(); var expectedTransactions = new CustomFormatTransactionData();
expectedTransactions.DeletedCustomFormatIds.Add(new TrashIdMapping("", "", 2)); expectedTransactions.DeletedCustomFormatIds.Add(new TrashIdMapping("", "", 2));
expectedTransactions.UpdatedCustomFormats.Add(guideCfs[0]); expectedTransactions.UpdatedCustomFormats.Add(guideCfs[0]);
processor.Transactions.Should().BeEquivalentTo(expectedTransactions); processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
processor.Transactions.UpdatedCustomFormats.First().Json.Should() processor.Transactions.UpdatedCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJson), op => op.Using(new JsonEquivalencyStep())); .BeEquivalentTo(JObject.Parse(expectedJson), op => op.Using(new JsonEquivalencyStep()));
} }
[Test] [Test]
public void Only_delete_correct_cfs() public void Only_delete_correct_cfs()
{ {
const string radarrCfData = @"[{ const string radarrCfData = @"[{
'id': 1, 'id': 1,
'name': 'not_deleted', 'name': 'not_deleted',
'specifications': [{ 'specifications': [{
@ -347,26 +347,26 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}] }]
}] }]
}]"; }]";
var deletedCfsInCache = new List<TrashIdMapping> var deletedCfsInCache = new List<TrashIdMapping>
{ {
new("testtrashid", "testname") {CustomFormatId = 2}, new("testtrashid", "testname") {CustomFormatId = 2},
new("", "not_deleted") {CustomFormatId = 3} new("", "not_deleted") {CustomFormatId = 3}
}; };
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData); var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData);
var processor = new JsonTransactionStep(); var processor = new JsonTransactionStep();
processor.RecordDeletions(deletedCfsInCache, radarrCfs!); processor.RecordDeletions(deletedCfsInCache, radarrCfs!);
var expectedTransactions = new CustomFormatTransactionData(); var expectedTransactions = new CustomFormatTransactionData();
expectedTransactions.DeletedCustomFormatIds.Add(new TrashIdMapping("testtrashid", "testname", 2)); expectedTransactions.DeletedCustomFormatIds.Add(new TrashIdMapping("testtrashid", "testname", 2));
processor.Transactions.Should().BeEquivalentTo(expectedTransactions); processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
} }
[Test] [Test]
public void Updated_and_unchanged_custom_formats_have_cache_entry_set_when_there_is_no_cache() public void Updated_and_unchanged_custom_formats_have_cache_entry_set_when_there_is_no_cache()
{ {
const string radarrCfData = @"[{ const string radarrCfData = @"[{
'id': 1, 'id': 1,
'name': 'updated', 'name': 'updated',
'specifications': [{ 'specifications': [{
@ -388,7 +388,7 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}] }]
}] }]
}]"; }]";
var guideCfData = JsonConvert.DeserializeObject<List<JObject>>(@"[{ var guideCfData = JsonConvert.DeserializeObject<List<JObject>>(@"[{
'name': 'updated', 'name': 'updated',
'specifications': [{ 'specifications': [{
'name': 'spec2', 'name': 'spec2',
@ -407,21 +407,20 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}] }]
}]"); }]");
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData); var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData);
var guideCfs = new List<ProcessedCustomFormatData> var guideCfs = new List<ProcessedCustomFormatData>
{ {
new("updated", "", guideCfData![0]), new("updated", "", guideCfData![0]),
new("no_change", "", guideCfData[1]) new("no_change", "", guideCfData[1])
}; };
var processor = new JsonTransactionStep(); var processor = new JsonTransactionStep();
processor.Process(guideCfs, radarrCfs!); processor.Process(guideCfs, radarrCfs!);
processor.Transactions.UpdatedCustomFormats.First().CacheEntry.Should() processor.Transactions.UpdatedCustomFormats.First().CacheEntry.Should()
.BeEquivalentTo(new TrashIdMapping("", "updated", 1)); .BeEquivalentTo(new TrashIdMapping("", "updated", 1));
processor.Transactions.UnchangedCustomFormats.First().CacheEntry.Should() processor.Transactions.UnchangedCustomFormats.First().CacheEntry.Should()
.BeEquivalentTo(new TrashIdMapping("", "no_change", 2)); .BeEquivalentTo(new TrashIdMapping("", "no_change", 2));
}
} }
} }

@ -12,16 +12,16 @@ using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache; using TrashLib.Radarr.CustomFormat.Models.Cache;
using TrashLib.Radarr.CustomFormat.Processors.PersistenceSteps; using TrashLib.Radarr.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class QualityProfileApiPersistenceStepTest
{ {
[TestFixture] [Test]
[Parallelizable(ParallelScope.All)] public void Do_not_invoke_api_if_no_scores_to_update()
public class QualityProfileApiPersistenceStepTest
{ {
[Test] const string radarrQualityProfileData = @"[{
public void Do_not_invoke_api_if_no_scores_to_update()
{
const string radarrQualityProfileData = @"[{
'name': 'profile1', 'name': 'profile1',
'formatItems': [{ 'formatItems': [{
'format': 1, 'format': 1,
@ -42,50 +42,50 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
'id': 1 'id': 1
}]"; }]";
var api = Substitute.For<IQualityProfileService>(); var api = Substitute.For<IQualityProfileService>();
api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData)); api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping> var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{ {
{ "profile1", CfTestUtils.NewMapping(
"profile1", CfTestUtils.NewMapping( new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject()) {
{ CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 4}
CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 4} }, 100))
}, 100)) }
} };
};
var processor = new QualityProfileApiPersistenceStep(); var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores); processor.Process(api, cfScores);
api.DidNotReceive().UpdateQualityProfile(Arg.Any<JObject>(), Arg.Any<int>()); api.DidNotReceive().UpdateQualityProfile(Arg.Any<JObject>(), Arg.Any<int>());
} }
[Test] [Test]
public void Invalid_quality_profile_names_are_reported() public void Invalid_quality_profile_names_are_reported()
{ {
const string radarrQualityProfileData = @"[{'name': 'profile1'}]"; const string radarrQualityProfileData = @"[{'name': 'profile1'}]";
var api = Substitute.For<IQualityProfileService>(); var api = Substitute.For<IQualityProfileService>();
api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData)); api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping> var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{ {
{"wrong_profile_name", CfTestUtils.NewMapping()} {"wrong_profile_name", CfTestUtils.NewMapping()}
}; };
var processor = new QualityProfileApiPersistenceStep(); var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores); processor.Process(api, cfScores);
processor.InvalidProfileNames.Should().Equal("wrong_profile_name"); processor.InvalidProfileNames.Should().Equal("wrong_profile_name");
processor.UpdatedScores.Should().BeEmpty(); processor.UpdatedScores.Should().BeEmpty();
} }
[Test] [Test]
public void Reset_scores_for_unmatched_cfs_if_enabled() public void Reset_scores_for_unmatched_cfs_if_enabled()
{ {
const string radarrQualityProfileData = @"[{ const string radarrQualityProfileData = @"[{
'name': 'profile1', 'name': 'profile1',
'formatItems': [{ 'formatItems': [{
'format': 1, 'format': 1,
@ -106,42 +106,42 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
'id': 1 'id': 1
}]"; }]";
var api = Substitute.For<IQualityProfileService>(); var api = Substitute.For<IQualityProfileService>();
api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData)); api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping> var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{ {
{ "profile1", CfTestUtils.NewMappingWithReset(
"profile1", CfTestUtils.NewMappingWithReset( new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject()) {
{ CacheEntry = new TrashIdMapping("", "", 2)
CacheEntry = new TrashIdMapping("", "", 2) }, 100))
}, 100)) }
} };
};
var processor = new QualityProfileApiPersistenceStep(); var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores); processor.Process(api, cfScores);
processor.InvalidProfileNames.Should().BeEmpty(); processor.InvalidProfileNames.Should().BeEmpty();
processor.UpdatedScores.Should() processor.UpdatedScores.Should()
.ContainKey("profile1").WhoseValue.Should() .ContainKey("profile1").WhoseValue.Should()
.BeEquivalentTo(new List<UpdatedFormatScore> .BeEquivalentTo(new List<UpdatedFormatScore>
{ {
new("cf1", 0, FormatScoreUpdateReason.Reset), new("cf1", 0, FormatScoreUpdateReason.Reset),
new("cf2", 100, FormatScoreUpdateReason.Updated), new("cf2", 100, FormatScoreUpdateReason.Updated),
new("cf3", 0, FormatScoreUpdateReason.Reset) new("cf3", 0, FormatScoreUpdateReason.Reset)
}); });
api.Received().UpdateQualityProfile( api.Received().UpdateQualityProfile(
Verify.That<JObject>(j => j["formatItems"]!.Children().Should().HaveCount(3)), Verify.That<JObject>(j => j["formatItems"]!.Children().Should().HaveCount(3)),
Arg.Any<int>()); Arg.Any<int>());
} }
[Test] [Test]
public void Scores_are_set_in_quality_profile() public void Scores_are_set_in_quality_profile()
{ {
const string radarrQualityProfileData = @"[{ const string radarrQualityProfileData = @"[{
'name': 'profile1', 'name': 'profile1',
'upgradeAllowed': false, 'upgradeAllowed': false,
'cutoff': 20, 'cutoff': 20,
@ -182,35 +182,35 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
'id': 1 'id': 1
}]"; }]";
var api = Substitute.For<IQualityProfileService>(); var api = Substitute.For<IQualityProfileService>();
api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData)); api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping> var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{ {
{ "profile1", CfTestUtils.NewMapping(
"profile1", CfTestUtils.NewMapping( new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject()) {
{ // First match by ID
// First match by ID CacheEntry = new TrashIdMapping("", "", 4)
CacheEntry = new TrashIdMapping("", "", 4) }, 100),
}, 100), new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject()) {
{ // Should NOT match because we do not use names to assign scores
// Should NOT match because we do not use names to assign scores CacheEntry = new TrashIdMapping("", "BR-DISK")
CacheEntry = new TrashIdMapping("", "BR-DISK") }, 101),
}, 101), new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject()) {
{ // Second match by ID
// Second match by ID CacheEntry = new TrashIdMapping("", "", 1)
CacheEntry = new TrashIdMapping("", "", 1) }, 102))
}, 102)) }
} };
};
var processor = new QualityProfileApiPersistenceStep(); var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores); processor.Process(api, cfScores);
var expectedProfileJson = JObject.Parse(@"{ var expectedProfileJson = JObject.Parse(@"{
'name': 'profile1', 'name': 'profile1',
'upgradeAllowed': false, 'upgradeAllowed': false,
'cutoff': 20, 'cutoff': 20,
@ -251,16 +251,15 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
'id': 1 'id': 1
}"); }");
api.Received() api.Received()
.UpdateQualityProfile(Verify.That<JObject>(a => a.Should().BeEquivalentTo(expectedProfileJson)), 1); .UpdateQualityProfile(Verify.That<JObject>(a => a.Should().BeEquivalentTo(expectedProfileJson)), 1);
processor.InvalidProfileNames.Should().BeEmpty(); processor.InvalidProfileNames.Should().BeEmpty();
processor.UpdatedScores.Should() processor.UpdatedScores.Should()
.ContainKey("profile1").WhoseValue.Should() .ContainKey("profile1").WhoseValue.Should()
.BeEquivalentTo(new List<UpdatedFormatScore> .BeEquivalentTo(new List<UpdatedFormatScore>
{ {
new("3D", 100, FormatScoreUpdateReason.Updated), new("3D", 100, FormatScoreUpdateReason.Updated),
new("asdf2", 102, FormatScoreUpdateReason.Updated) new("asdf2", 102, FormatScoreUpdateReason.Updated)
}); });
}
} }
} }

@ -2,120 +2,119 @@ using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using TrashLib.Radarr.QualityDefinition; using TrashLib.Radarr.QualityDefinition;
namespace TrashLib.Tests.Radarr.QualityDefinition namespace TrashLib.Tests.Radarr.QualityDefinition;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class RadarrQualityDataTest
{ {
[TestFixture] private static readonly object[] PreferredTestValues =
[Parallelizable(ParallelScope.All)]
public class RadarrQualityDataTest
{ {
private static readonly object[] PreferredTestValues = new object?[] {100m, 100m, false},
{ new object?[] {100m, 101m, true},
new object?[] {100m, 100m, false}, new object?[] {100m, 98m, true},
new object?[] {100m, 101m, true}, new object?[] {100m, null, true},
new object?[] {100m, 98m, true}, new object?[] {RadarrQualityData.PreferredUnlimitedThreshold, null, false},
new object?[] {100m, null, true}, new object?[] {RadarrQualityData.PreferredUnlimitedThreshold - 1, null, true},
new object?[] {RadarrQualityData.PreferredUnlimitedThreshold, null, false}, new object?[]
new object?[] {RadarrQualityData.PreferredUnlimitedThreshold - 1, null, true}, {RadarrQualityData.PreferredUnlimitedThreshold, RadarrQualityData.PreferredUnlimitedThreshold, true}
new object?[] };
{RadarrQualityData.PreferredUnlimitedThreshold, RadarrQualityData.PreferredUnlimitedThreshold, true}
};
[TestCaseSource(nameof(PreferredTestValues))] [TestCaseSource(nameof(PreferredTestValues))]
public void PreferredDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal? radarrValue, public void PreferredDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal? radarrValue,
bool isDifferent) bool isDifferent)
{ {
var data = new RadarrQualityData {Preferred = guideValue}; var data = new RadarrQualityData {Preferred = guideValue};
data.IsPreferredDifferent(radarrValue) data.IsPreferredDifferent(radarrValue)
.Should().Be(isDifferent); .Should().Be(isDifferent);
} }
private static readonly object[] InterpolatedPreferredTestParams = private static readonly object[] InterpolatedPreferredTestParams =
{
new[]
{ {
new[] 400m,
{ 1.0m,
400m, RadarrQualityData.PreferredUnlimitedThreshold
1.0m, },
RadarrQualityData.PreferredUnlimitedThreshold new[]
},
new[]
{
RadarrQualityData.PreferredUnlimitedThreshold,
1.0m,
RadarrQualityData.PreferredUnlimitedThreshold
},
new[]
{
RadarrQualityData.PreferredUnlimitedThreshold - 1m,
1.0m,
RadarrQualityData.PreferredUnlimitedThreshold - 1m
},
new[]
{
10m,
0m,
0m
},
new[]
{
100m,
0.5m,
50m
}
};
[TestCaseSource(nameof(InterpolatedPreferredTestParams))]
public void InterpolatedPreferred_VariousValues_ExpectedResults(decimal max, decimal ratio,
decimal expectedResult)
{ {
var data = new RadarrQualityData {Min = 0, Max = max}; RadarrQualityData.PreferredUnlimitedThreshold,
data.InterpolatedPreferred(ratio).Should().Be(expectedResult); 1.0m,
} RadarrQualityData.PreferredUnlimitedThreshold
},
[Test] new[]
public void AnnotatedPreferred_OutsideThreshold_EqualsSameValueWithUnlimited()
{ {
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold; RadarrQualityData.PreferredUnlimitedThreshold - 1m,
var data = new RadarrQualityData {Preferred = testVal}; 1.0m,
data.AnnotatedPreferred.Should().Be($"{testVal} (Unlimited)"); RadarrQualityData.PreferredUnlimitedThreshold - 1m
} },
new[]
[Test]
public void AnnotatedPreferred_WithinThreshold_EqualsSameStringValue()
{ {
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold - 1; 10m,
var data = new RadarrQualityData {Preferred = testVal}; 0m,
data.AnnotatedPreferred.Should().Be($"{testVal}"); 0m
} },
new[]
[Test]
public void Preferred_AboveThreshold_EqualsSameValue()
{ {
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold + 1; 100m,
var data = new RadarrQualityData {Preferred = testVal}; 0.5m,
data.Preferred.Should().Be(testVal); 50m
} }
};
[Test] [TestCaseSource(nameof(InterpolatedPreferredTestParams))]
public void PreferredForApi_AboveThreshold_EqualsNull() public void InterpolatedPreferred_VariousValues_ExpectedResults(decimal max, decimal ratio,
{ decimal expectedResult)
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold + 1; {
var data = new RadarrQualityData {Preferred = testVal}; var data = new RadarrQualityData {Min = 0, Max = max};
data.PreferredForApi.Should().Be(null); data.InterpolatedPreferred(ratio).Should().Be(expectedResult);
} }
[Test] [Test]
public void PreferredForApi_HighestWithinThreshold_EqualsSameValue() public void AnnotatedPreferred_OutsideThreshold_EqualsSameValueWithUnlimited()
{ {
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold - 0.1m; const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold;
var data = new RadarrQualityData {Preferred = testVal}; var data = new RadarrQualityData {Preferred = testVal};
data.PreferredForApi.Should().Be(testVal).And.Be(data.Preferred); data.AnnotatedPreferred.Should().Be($"{testVal} (Unlimited)");
} }
[Test] [Test]
public void PreferredForApi_LowestWithinThreshold_EqualsSameValue() public void AnnotatedPreferred_WithinThreshold_EqualsSameStringValue()
{ {
var data = new RadarrQualityData {Preferred = 0}; const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold - 1;
data.PreferredForApi.Should().Be(0); var data = new RadarrQualityData {Preferred = testVal};
} data.AnnotatedPreferred.Should().Be($"{testVal}");
}
[Test]
public void Preferred_AboveThreshold_EqualsSameValue()
{
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold + 1;
var data = new RadarrQualityData {Preferred = testVal};
data.Preferred.Should().Be(testVal);
}
[Test]
public void PreferredForApi_AboveThreshold_EqualsNull()
{
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold + 1;
var data = new RadarrQualityData {Preferred = testVal};
data.PreferredForApi.Should().Be(null);
}
[Test]
public void PreferredForApi_HighestWithinThreshold_EqualsSameValue()
{
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold - 0.1m;
var data = new RadarrQualityData {Preferred = testVal};
data.PreferredForApi.Should().Be(testVal).And.Be(data.Preferred);
}
[Test]
public void PreferredForApi_LowestWithinThreshold_EqualsSameValue()
{
var data = new RadarrQualityData {Preferred = 0};
data.PreferredForApi.Should().Be(0);
} }
} }

@ -10,102 +10,101 @@ using TrashLib.Radarr;
using TrashLib.Radarr.Config; using TrashLib.Radarr.Config;
using TrashLib.Radarr.QualityDefinition; using TrashLib.Radarr.QualityDefinition;
namespace TrashLib.Tests.Radarr namespace TrashLib.Tests.Radarr;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class RadarrConfigurationTest
{ {
[TestFixture] private IContainer _container = default!;
[Parallelizable(ParallelScope.All)]
public class RadarrConfigurationTest
{
private IContainer _container = default!;
[OneTimeSetUp] [OneTimeSetUp]
public void Setup() public void Setup()
{ {
var builder = new ContainerBuilder(); var builder = new ContainerBuilder();
builder.RegisterModule<ConfigAutofacModule>(); builder.RegisterModule<ConfigAutofacModule>();
builder.RegisterModule<RadarrAutofacModule>(); builder.RegisterModule<RadarrAutofacModule>();
_container = builder.Build(); _container = builder.Build();
} }
private static readonly TestCaseData[] NameOrIdsTestData = private static readonly TestCaseData[] NameOrIdsTestData =
{ {
new(new Collection<string> {"name"}, new Collection<string>()), new(new Collection<string> {"name"}, new Collection<string>()),
new(new Collection<string>(), new Collection<string> {"trash_id"}) new(new Collection<string>(), new Collection<string> {"trash_id"})
}; };
[TestCaseSource(nameof(NameOrIdsTestData))] [TestCaseSource(nameof(NameOrIdsTestData))]
public void Custom_format_is_valid_with_one_of_either_names_or_trash_id(Collection<string> namesList, public void Custom_format_is_valid_with_one_of_either_names_or_trash_id(Collection<string> namesList,
Collection<string> trashIdsList) Collection<string> trashIdsList)
{
var config = new RadarrConfiguration
{ {
var config = new RadarrConfiguration ApiKey = "required value",
BaseUrl = "required value",
CustomFormats = new List<CustomFormatConfig>
{ {
ApiKey = "required value", new() {Names = namesList, TrashIds = trashIdsList}
BaseUrl = "required value", }
CustomFormats = new List<CustomFormatConfig> };
{
new() {Names = namesList, TrashIds = trashIdsList}
}
};
var validator = _container.Resolve<IValidator<RadarrConfiguration>>(); var validator = _container.Resolve<IValidator<RadarrConfiguration>>();
var result = validator.Validate(config); var result = validator.Validate(config);
result.IsValid.Should().BeTrue(); result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty(); result.Errors.Should().BeEmpty();
} }
[Test] [Test]
public void Validation_fails_for_all_missing_required_properties() public void Validation_fails_for_all_missing_required_properties()
{ {
// default construct which should yield default values (invalid) for all required properties // default construct which should yield default values (invalid) for all required properties
var config = new RadarrConfiguration(); var config = new RadarrConfiguration();
var validator = _container.Resolve<IValidator<RadarrConfiguration>>(); var validator = _container.Resolve<IValidator<RadarrConfiguration>>();
var result = validator.Validate(config); var result = validator.Validate(config);
var expectedErrorMessageSubstrings = new[] var expectedErrorMessageSubstrings = new[]
{ {
"Property 'base_url' is required", "Property 'base_url' is required",
"Property 'api_key' is required", "Property 'api_key' is required",
"'custom_formats' elements must contain at least one element in either 'names' or 'trash_ids'", "'custom_formats' elements must contain at least one element in either 'names' or 'trash_ids'",
"'name' is required for elements under 'quality_profiles'", "'name' is required for elements under 'quality_profiles'",
"'type' is required for 'quality_definition'" "'type' is required for 'quality_definition'"
}; };
result.IsValid.Should().BeFalse(); result.IsValid.Should().BeFalse();
result.Errors.Select(e => e.ErrorMessage).Should() result.Errors.Select(e => e.ErrorMessage).Should()
.OnlyContain(x => expectedErrorMessageSubstrings.Any(x.Contains)); .OnlyContain(x => expectedErrorMessageSubstrings.Any(x.Contains));
} }
[Test] [Test]
public void Validation_succeeds_when_no_missing_required_properties() public void Validation_succeeds_when_no_missing_required_properties()
{
var config = new RadarrConfiguration
{ {
var config = new RadarrConfiguration ApiKey = "required value",
BaseUrl = "required value",
CustomFormats = new List<CustomFormatConfig>
{ {
ApiKey = "required value", new()
BaseUrl = "required value",
CustomFormats = new List<CustomFormatConfig>
{ {
new() Names = new List<string> {"required value"},
QualityProfiles = new List<QualityProfileConfig>
{ {
Names = new List<string> {"required value"}, new() {Name = "required value"}
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "required value"}
}
} }
},
QualityDefinition = new QualityDefinitionConfig
{
Type = RadarrQualityDefinitionType.Movie
} }
}; },
QualityDefinition = new QualityDefinitionConfig
{
Type = RadarrQualityDefinitionType.Movie
}
};
var validator = _container.Resolve<IValidator<RadarrConfiguration>>(); var validator = _container.Resolve<IValidator<RadarrConfiguration>>();
var result = validator.Validate(config); var result = validator.Validate(config);
result.IsValid.Should().BeTrue(); result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty(); result.Errors.Should().BeEmpty();
}
} }
} }

@ -14,105 +14,104 @@ using TrashLib.Sonarr.Api;
using TrashLib.Sonarr.Api.Objects; using TrashLib.Sonarr.Api.Objects;
using TrashLib.Startup; using TrashLib.Startup;
namespace TrashLib.Tests.Sonarr.Api namespace TrashLib.Tests.Sonarr.Api;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrReleaseProfileCompatibilityHandlerTest
{ {
[TestFixture] private class TestContext : IDisposable
[Parallelizable(ParallelScope.All)]
public class SonarrReleaseProfileCompatibilityHandlerTest
{ {
private class TestContext : IDisposable private readonly JsonSerializerSettings _jsonSettings;
{
private readonly JsonSerializerSettings _jsonSettings;
public TestContext() public TestContext()
{
_jsonSettings = new JsonSerializerSettings
{ {
_jsonSettings = new JsonSerializerSettings ContractResolver = new CamelCasePropertyNamesContractResolver()
{ };
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
Mapper = AutoMapperConfig.Setup();
}
public IMapper Mapper { get; } Mapper = AutoMapperConfig.Setup();
}
public void Dispose() public IMapper Mapper { get; }
{
}
public string SerializeJson<T>(T obj) public void Dispose()
{ {
return JsonConvert.SerializeObject(obj, _jsonSettings);
}
} }
[Test] public string SerializeJson<T>(T obj)
public void Receive_v1_to_v2()
{ {
using var ctx = new TestContext(); return JsonConvert.SerializeObject(obj, _jsonSettings);
}
}
var compat = Substitute.For<ISonarrCompatibility>(); [Test]
var dataV1 = new SonarrReleaseProfileV1 {Ignored = "one,two,three"}; public void Receive_v1_to_v2()
var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper); {
using var ctx = new TestContext();
var result = sut.CompatibleReleaseProfileForReceiving(JObject.Parse(ctx.SerializeJson(dataV1))); var compat = Substitute.For<ISonarrCompatibility>();
var dataV1 = new SonarrReleaseProfileV1 {Ignored = "one,two,three"};
var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper);
result.Should().BeEquivalentTo(new SonarrReleaseProfile var result = sut.CompatibleReleaseProfileForReceiving(JObject.Parse(ctx.SerializeJson(dataV1)));
{
Ignored = new List<string> {"one", "two", "three"}
});
}
[Test] result.Should().BeEquivalentTo(new SonarrReleaseProfile
public void Receive_v2_to_v2()
{ {
using var ctx = new TestContext(); Ignored = new List<string> {"one", "two", "three"}
});
}
var compat = Substitute.For<ISonarrCompatibility>(); [Test]
var dataV2 = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}}; public void Receive_v2_to_v2()
var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper); {
using var ctx = new TestContext();
var result = sut.CompatibleReleaseProfileForReceiving(JObject.Parse(ctx.SerializeJson(dataV2))); var compat = Substitute.For<ISonarrCompatibility>();
var dataV2 = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper);
result.Should().BeEquivalentTo(dataV2); var result = sut.CompatibleReleaseProfileForReceiving(JObject.Parse(ctx.SerializeJson(dataV2)));
}
result.Should().BeEquivalentTo(dataV2);
}
[Test] [Test]
public async Task Send_v2_to_v1() public async Task Send_v2_to_v1()
{
using var ctx = new TestContext();
var compat = Substitute.For<ISonarrCompatibility>();
compat.Capabilities.Returns(new[]
{ {
using var ctx = new TestContext(); new SonarrCapabilities {ArraysNeededForReleaseProfileRequiredAndIgnored = false}
}.ToObservable());
var compat = Substitute.For<ISonarrCompatibility>(); var data = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
compat.Capabilities.Returns(new[] var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper);
{
new SonarrCapabilities {ArraysNeededForReleaseProfileRequiredAndIgnored = false}
}.ToObservable());
var data = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}}; var result = await sut.CompatibleReleaseProfileForSendingAsync(data);
var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper);
var result = await sut.CompatibleReleaseProfileForSendingAsync(data); result.Should().BeEquivalentTo(new SonarrReleaseProfileV1 {Ignored = "one,two,three"});
}
result.Should().BeEquivalentTo(new SonarrReleaseProfileV1 {Ignored = "one,two,three"}); [Test]
} public async Task Send_v2_to_v2()
{
using var ctx = new TestContext();
[Test] var compat = Substitute.For<ISonarrCompatibility>();
public async Task Send_v2_to_v2() compat.Capabilities.Returns(new[]
{ {
using var ctx = new TestContext(); new SonarrCapabilities {ArraysNeededForReleaseProfileRequiredAndIgnored = true}
}.ToObservable());
var compat = Substitute.For<ISonarrCompatibility>(); var data = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
compat.Capabilities.Returns(new[] var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper);
{
new SonarrCapabilities {ArraysNeededForReleaseProfileRequiredAndIgnored = true}
}.ToObservable());
var data = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper);
var result = await sut.CompatibleReleaseProfileForSendingAsync(data); var result = await sut.CompatibleReleaseProfileForSendingAsync(data);
result.Should().BeEquivalentTo(data); result.Should().BeEquivalentTo(data);
}
} }
} }

@ -2,101 +2,100 @@ using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using TrashLib.Sonarr.QualityDefinition; using TrashLib.Sonarr.QualityDefinition;
namespace TrashLib.Tests.Sonarr.QualityDefinition namespace TrashLib.Tests.Sonarr.QualityDefinition;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrQualityDataTest
{ {
[TestFixture] private static readonly object[] MaxTestValues =
[Parallelizable(ParallelScope.All)]
public class SonarrQualityDataTest
{ {
private static readonly object[] MaxTestValues = new object?[] {100m, 100m, false},
{ new object?[] {100m, 101m, true},
new object?[] {100m, 100m, false}, new object?[] {100m, 98m, true},
new object?[] {100m, 101m, true}, new object?[] {100m, null, true},
new object?[] {100m, 98m, true}, new object?[] {SonarrQualityData.MaxUnlimitedThreshold, null, false},
new object?[] {100m, null, true}, new object?[] {SonarrQualityData.MaxUnlimitedThreshold - 1, null, true},
new object?[] {SonarrQualityData.MaxUnlimitedThreshold, null, false}, new object?[] {SonarrQualityData.MaxUnlimitedThreshold, SonarrQualityData.MaxUnlimitedThreshold, true}
new object?[] {SonarrQualityData.MaxUnlimitedThreshold - 1, null, true}, };
new object?[] {SonarrQualityData.MaxUnlimitedThreshold, SonarrQualityData.MaxUnlimitedThreshold, true}
};
private static readonly object[] MinTestValues = private static readonly object[] MinTestValues =
{ {
new object?[] {0m, 0m, false}, new object?[] {0m, 0m, false},
new object?[] {0m, -1m, true}, new object?[] {0m, -1m, true},
new object?[] {0m, 1m, true} new object?[] {0m, 1m, true}
}; };
[TestCaseSource(nameof(MaxTestValues))] [TestCaseSource(nameof(MaxTestValues))]
public void MaxDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal? radarrValue, public void MaxDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal? radarrValue,
bool isDifferent) bool isDifferent)
{ {
var data = new SonarrQualityData {Max = guideValue}; var data = new SonarrQualityData {Max = guideValue};
data.IsMaxDifferent(radarrValue) data.IsMaxDifferent(radarrValue)
.Should().Be(isDifferent); .Should().Be(isDifferent);
} }
[TestCaseSource(nameof(MinTestValues))] [TestCaseSource(nameof(MinTestValues))]
public void MinDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal radarrValue, public void MinDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal radarrValue,
bool isDifferent) bool isDifferent)
{ {
var data = new SonarrQualityData {Min = guideValue}; var data = new SonarrQualityData {Min = guideValue};
data.IsMinDifferent(radarrValue) data.IsMinDifferent(radarrValue)
.Should().Be(isDifferent); .Should().Be(isDifferent);
} }
[Test] [Test]
public void AnnotatedMax_OutsideThreshold_EqualsSameValueWithUnlimited() public void AnnotatedMax_OutsideThreshold_EqualsSameValueWithUnlimited()
{ {
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold; const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold;
var data = new SonarrQualityData {Max = testVal}; var data = new SonarrQualityData {Max = testVal};
data.AnnotatedMax.Should().Be($"{testVal} (Unlimited)"); data.AnnotatedMax.Should().Be($"{testVal} (Unlimited)");
} }
[Test] [Test]
public void AnnotatedMax_WithinThreshold_EqualsSameStringValue() public void AnnotatedMax_WithinThreshold_EqualsSameStringValue()
{ {
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold - 1; const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold - 1;
var data = new SonarrQualityData {Max = testVal}; var data = new SonarrQualityData {Max = testVal};
data.AnnotatedMax.Should().Be($"{testVal}"); data.AnnotatedMax.Should().Be($"{testVal}");
} }
[Test] [Test]
public void AnnotatedMin_NoThreshold_EqualsSameValue() public void AnnotatedMin_NoThreshold_EqualsSameValue()
{ {
const decimal testVal = 10m; const decimal testVal = 10m;
var data = new SonarrQualityData {Max = testVal}; var data = new SonarrQualityData {Max = testVal};
data.AnnotatedMax.Should().Be($"{testVal}"); data.AnnotatedMax.Should().Be($"{testVal}");
} }
[Test] [Test]
public void Max_AboveThreshold_EqualsSameValue() public void Max_AboveThreshold_EqualsSameValue()
{ {
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold + 1; const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold + 1;
var data = new SonarrQualityData {Max = testVal}; var data = new SonarrQualityData {Max = testVal};
data.Max.Should().Be(testVal); data.Max.Should().Be(testVal);
} }
[Test] [Test]
public void MaxForApi_AboveThreshold_EqualsNull() public void MaxForApi_AboveThreshold_EqualsNull()
{ {
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold + 1; const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold + 1;
var data = new SonarrQualityData {Max = testVal}; var data = new SonarrQualityData {Max = testVal};
data.MaxForApi.Should().Be(null); data.MaxForApi.Should().Be(null);
} }
[Test] [Test]
public void MaxForApi_HighestWithinThreshold_EqualsSameValue() public void MaxForApi_HighestWithinThreshold_EqualsSameValue()
{ {
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold - 0.1m; const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold - 0.1m;
var data = new SonarrQualityData {Max = testVal}; var data = new SonarrQualityData {Max = testVal};
data.MaxForApi.Should().Be(testVal).And.Be(data.Max); data.MaxForApi.Should().Be(testVal).And.Be(data.Max);
} }
[Test] [Test]
public void MaxForApi_LowestWithinThreshold_EqualsSameValue() public void MaxForApi_LowestWithinThreshold_EqualsSameValue()
{ {
var data = new SonarrQualityData {Max = 0}; var data = new SonarrQualityData {Max = 0};
data.MaxForApi.Should().Be(0); data.MaxForApi.Should().Be(0);
}
} }
} }

@ -4,89 +4,88 @@ using NUnit.Framework;
using TrashLib.Sonarr.Config; using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile; using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr.ReleaseProfile namespace TrashLib.Tests.Sonarr.ReleaseProfile;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class FilteredProfileDataTest
{ {
[TestFixture] [Test]
[Parallelizable(ParallelScope.All)] public void Filter_ExcludeOptional_HasNoOptionalItems()
public class FilteredProfileDataTest
{ {
[Test] var config = new ReleaseProfileConfig();
public void Filter_ExcludeOptional_HasNoOptionalItems() config.Filter.IncludeOptional = false;
{
var config = new ReleaseProfileConfig();
config.Filter.IncludeOptional = false;
var profileData = new ProfileData var profileData = new ProfileData
{
Ignored = new List<string> {"ignored1"},
Required = new List<string> {"required1"},
Preferred = new Dictionary<int, List<string>>
{ {
Ignored = new List<string> {"ignored1"}, {100, new List<string> {"preferred1"}}
Required = new List<string> {"required1"}, },
Optional = new ProfileDataOptional
{
Ignored = new List<string> {"ignored2"},
Required = new List<string> {"required2"},
Preferred = new Dictionary<int, List<string>> Preferred = new Dictionary<int, List<string>>
{ {
{100, new List<string> {"preferred1"}} {200, new List<string> {"preferred2"}},
}, {100, new List<string> {"preferred3"}}
Optional = new ProfileDataOptional
{
Ignored = new List<string> {"ignored2"},
Required = new List<string> {"required2"},
Preferred = new Dictionary<int, List<string>>
{
{200, new List<string> {"preferred2"}},
{100, new List<string> {"preferred3"}}
}
} }
}; }
};
var filtered = new FilteredProfileData(profileData, config); var filtered = new FilteredProfileData(profileData, config);
filtered.Should().BeEquivalentTo(new filtered.Should().BeEquivalentTo(new
{
Ignored = new List<string> {"ignored1"},
Required = new List<string> {"required1"},
Preferred = new Dictionary<int, List<string>>
{ {
Ignored = new List<string> {"ignored1"}, {100, new List<string> {"preferred1"}}
Required = new List<string> {"required1"}, }
Preferred = new Dictionary<int, List<string>> });
{ }
{100, new List<string> {"preferred1"}}
}
});
}
[Test] [Test]
public void Filter_IncludeOptional_HasAllOptionalItems() public void Filter_IncludeOptional_HasAllOptionalItems()
{ {
var config = new ReleaseProfileConfig(); var config = new ReleaseProfileConfig();
config.Filter.IncludeOptional = true; config.Filter.IncludeOptional = true;
var profileData = new ProfileData var profileData = new ProfileData
{
Ignored = new List<string> {"ignored1"},
Required = new List<string> {"required1"},
Preferred = new Dictionary<int, List<string>>
{ {
Ignored = new List<string> {"ignored1"}, {100, new List<string> {"preferred1"}}
Required = new List<string> {"required1"}, },
Optional = new ProfileDataOptional
{
Ignored = new List<string> {"ignored2"},
Required = new List<string> {"required2"},
Preferred = new Dictionary<int, List<string>> Preferred = new Dictionary<int, List<string>>
{ {
{100, new List<string> {"preferred1"}} {200, new List<string> {"preferred2"}},
}, {100, new List<string> {"preferred3"}}
Optional = new ProfileDataOptional
{
Ignored = new List<string> {"ignored2"},
Required = new List<string> {"required2"},
Preferred = new Dictionary<int, List<string>>
{
{200, new List<string> {"preferred2"}},
{100, new List<string> {"preferred3"}}
}
} }
}; }
};
var filtered = new FilteredProfileData(profileData, config); var filtered = new FilteredProfileData(profileData, config);
filtered.Should().BeEquivalentTo(new filtered.Should().BeEquivalentTo(new
{
Ignored = new List<string> {"ignored1", "ignored2"},
Required = new List<string> {"required1", "required2"},
Preferred = new Dictionary<int, List<string>>
{ {
Ignored = new List<string> {"ignored1", "ignored2"}, {100, new List<string> {"preferred1", "preferred3"}},
Required = new List<string> {"required1", "required2"}, {200, new List<string> {"preferred2"}}
Preferred = new Dictionary<int, List<string>> }
{ });
{100, new List<string> {"preferred1", "preferred3"}},
{200, new List<string> {"preferred2"}}
}
});
}
} }
} }

@ -9,49 +9,49 @@ using TestLibrary;
using TrashLib.Sonarr.Config; using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile; using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr.ReleaseProfile namespace TrashLib.Tests.Sonarr.ReleaseProfile;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ReleaseProfileParserTest
{ {
[TestFixture] [OneTimeSetUp]
[Parallelizable(ParallelScope.All)] public void Setup()
public class ReleaseProfileParserTest
{ {
[OneTimeSetUp] // Formatter.AddFormatter(new ProfileDataValueFormatter());
public void Setup() }
{
// Formatter.AddFormatter(new ProfileDataValueFormatter());
}
private class Context private class Context
{
public Context()
{ {
public Context() var logger = new LoggerConfiguration()
{ .WriteTo.TestCorrelator()
var logger = new LoggerConfiguration() .MinimumLevel.Debug()
.WriteTo.TestCorrelator() .CreateLogger();
.MinimumLevel.Debug()
.CreateLogger();
Config = new SonarrConfiguration Config = new SonarrConfiguration
{ {
ReleaseProfiles = new[] {new ReleaseProfileConfig()} ReleaseProfiles = new[] {new ReleaseProfileConfig()}
}; };
GuideParser = new ReleaseProfileGuideParser(logger); GuideParser = new ReleaseProfileGuideParser(logger);
} }
public SonarrConfiguration Config { get; } public SonarrConfiguration Config { get; }
public ReleaseProfileGuideParser GuideParser { get; } public ReleaseProfileGuideParser GuideParser { get; }
public ResourceDataReader TestData { get; } = new(typeof(ReleaseProfileParserTest), "Data"); public ResourceDataReader TestData { get; } = new(typeof(ReleaseProfileParserTest), "Data");
public IDictionary<string, ProfileData> ParseWithDefaults(string markdown) public IDictionary<string, ProfileData> ParseWithDefaults(string markdown)
{ {
return GuideParser.ParseMarkdown(Config.ReleaseProfiles.First(), markdown); return GuideParser.ParseMarkdown(Config.ReleaseProfiles.First(), markdown);
}
} }
}
[Test] [Test]
public void Parse_CodeBlockScopedCategories_CategoriesSwitch() public void Parse_CodeBlockScopedCategories_CategoriesSwitch()
{ {
var markdown = StringUtils.TrimmedString(@" var markdown = StringUtils.TrimmedString(@"
# Test Release Profile # Test Release Profile
Add this to must not contain (ignored) Add this to must not contain (ignored)
@ -66,21 +66,21 @@ Add this to must contain (required)
xyz xyz
``` ```
"); ");
var context = new Context(); var context = new Context();
var results = context.ParseWithDefaults(markdown); var results = context.ParseWithDefaults(markdown);
results.Should().ContainKey("Test Release Profile") results.Should().ContainKey("Test Release Profile")
.WhoseValue.Should().BeEquivalentTo(new .WhoseValue.Should().BeEquivalentTo(new
{ {
Ignored = new List<string> {"abc"}, Ignored = new List<string> {"abc"},
Required = new List<string> {"xyz"} Required = new List<string> {"xyz"}
}); });
} }
[Test] [Test]
public void Parse_HeaderCategoryFollowedByCodeBlockCategories_CodeBlockChangesCurrentCategory() public void Parse_HeaderCategoryFollowedByCodeBlockCategories_CodeBlockChangesCurrentCategory()
{ {
var markdown = StringUtils.TrimmedString(@" var markdown = StringUtils.TrimmedString(@"
# Test Release Profile # Test Release Profile
## Must Not Contain ## Must Not Contain
@ -103,52 +103,52 @@ One more
123 123
``` ```
"); ");
var context = new Context(); var context = new Context();
var results = context.ParseWithDefaults(markdown); var results = context.ParseWithDefaults(markdown);
results.Should().ContainKey("Test Release Profile") results.Should().ContainKey("Test Release Profile")
.WhoseValue.Should().BeEquivalentTo(new .WhoseValue.Should().BeEquivalentTo(new
{ {
Ignored = new List<string> {"abc"}, Ignored = new List<string> {"abc"},
Required = new List<string> {"xyz", "123"} Required = new List<string> {"xyz", "123"}
}); });
} }
[Test] [Test]
public void Parse_IgnoredRequiredPreferredScores() public void Parse_IgnoredRequiredPreferredScores()
{ {
var context = new Context(); var context = new Context();
var markdown = context.TestData.ReadData("test_parse_markdown_complete_doc.md"); var markdown = context.TestData.ReadData("test_parse_markdown_complete_doc.md");
var results = context.GuideParser.ParseMarkdown(context.Config.ReleaseProfiles.First(), markdown); var results = context.GuideParser.ParseMarkdown(context.Config.ReleaseProfiles.First(), markdown);
results.Count.Should().Be(1); results.Count.Should().Be(1);
var profile = results.First().Value; var profile = results.First().Value;
profile.Ignored.Should().BeEquivalentTo("term2", "term3"); profile.Ignored.Should().BeEquivalentTo("term2", "term3");
profile.Required.Should().BeEquivalentTo("term4"); profile.Required.Should().BeEquivalentTo("term4");
profile.Preferred.Should().ContainKey(100).WhoseValue.Should().BeEquivalentTo(new List<string> {"term1"}); profile.Preferred.Should().ContainKey(100).WhoseValue.Should().BeEquivalentTo(new List<string> {"term1"});
} }
[Test] [Test]
public void Parse_IncludePreferredWhenRenaming() public void Parse_IncludePreferredWhenRenaming()
{ {
var context = new Context(); var context = new Context();
var markdown = context.TestData.ReadData("include_preferred_when_renaming.md"); var markdown = context.TestData.ReadData("include_preferred_when_renaming.md");
var results = context.ParseWithDefaults(markdown); var results = context.ParseWithDefaults(markdown);
results.Should() results.Should()
.ContainKey("First Release Profile") .ContainKey("First Release Profile")
.WhoseValue.IncludePreferredWhenRenaming.Should().Be(true); .WhoseValue.IncludePreferredWhenRenaming.Should().Be(true);
results.Should() results.Should()
.ContainKey("Second Release Profile") .ContainKey("Second Release Profile")
.WhoseValue.IncludePreferredWhenRenaming.Should().Be(false); .WhoseValue.IncludePreferredWhenRenaming.Should().Be(false);
} }
[Test] [Test]
public void Parse_IndentedIncludePreferred_ShouldBeParsed() public void Parse_IndentedIncludePreferred_ShouldBeParsed()
{ {
var markdown = StringUtils.TrimmedString(@" var markdown = StringUtils.TrimmedString(@"
# Release Profile 1 # Release Profile 1
!!! Warning !!! Warning
@ -171,34 +171,34 @@ must contain
test2 test2
``` ```
"); ");
var context = new Context(); var context = new Context();
var results = context.ParseWithDefaults(markdown); var results = context.ParseWithDefaults(markdown);
var expectedResults = new Dictionary<string, ProfileData> var expectedResults = new Dictionary<string, ProfileData>
{
{ {
"Release Profile 1", new ProfileData
{ {
"Release Profile 1", new ProfileData IncludePreferredWhenRenaming = false,
{ Required = new List<string> {"test1"}
IncludePreferredWhenRenaming = false, }
Required = new List<string> {"test1"} },
} {
}, "Release Profile 2", new ProfileData
{ {
"Release Profile 2", new ProfileData IncludePreferredWhenRenaming = true,
{ Required = new List<string> {"test2"}
IncludePreferredWhenRenaming = true,
Required = new List<string> {"test2"}
}
} }
}; }
};
results.Should().BeEquivalentTo(expectedResults); results.Should().BeEquivalentTo(expectedResults);
} }
[Test] [Test]
public void Parse_OptionalTerms_AreCapturedProperly() public void Parse_OptionalTerms_AreCapturedProperly()
{ {
var markdown = StringUtils.TrimmedString(@" var markdown = StringUtils.TrimmedString(@"
# Optional Release Profile # Optional Release Profile
``` ```
@ -233,40 +233,40 @@ This must not contain:
not-optional1 not-optional1
``` ```
"); ");
var context = new Context(); var context = new Context();
var results = context.ParseWithDefaults(markdown); var results = context.ParseWithDefaults(markdown);
var expectedResults = new Dictionary<string, ProfileData> var expectedResults = new Dictionary<string, ProfileData>
{
{ {
"Optional Release Profile", new ProfileData
{ {
"Optional Release Profile", new ProfileData Optional = new ProfileDataOptional
{ {
Optional = new ProfileDataOptional Ignored = new List<string> {"optional1"},
Required = new List<string> {"optional3"},
Preferred = new Dictionary<int, List<string>>
{ {
Ignored = new List<string> {"optional1"}, {10, new List<string> {"optional2"}}
Required = new List<string> {"optional3"},
Preferred = new Dictionary<int, List<string>>
{
{10, new List<string> {"optional2"}}
}
} }
} }
}, }
},
{
"Second Release Profile", new ProfileData
{ {
"Second Release Profile", new ProfileData Ignored = new List<string> {"not-optional1"}
{
Ignored = new List<string> {"not-optional1"}
}
} }
}; }
};
results.Should().BeEquivalentTo(expectedResults); results.Should().BeEquivalentTo(expectedResults);
} }
[Test] [Test]
public void Parse_PotentialScore_WarningLogged() public void Parse_PotentialScore_WarningLogged()
{ {
string markdown = StringUtils.TrimmedString(@" var markdown = StringUtils.TrimmedString(@"
# First Release Profile # First Release Profile
The below line should be a score but isn't because it's missing the word 'score'. The below line should be a score but isn't because it's missing the word 'score'.
@ -277,23 +277,23 @@ Use this number [100]
abc abc
``` ```
"); ");
var context = new Context(); var context = new Context();
var results = context.ParseWithDefaults(markdown); var results = context.ParseWithDefaults(markdown);
results.Should().BeEmpty(); results.Should().BeEmpty();
const string expectedLog = const string expectedLog =
"Found a potential score on line #5 that will be ignored because the " + "Found a potential score on line #5 that will be ignored because the " +
"word 'score' is missing (This is probably a bug in the guide itself): \"[100]\""; "word 'score' is missing (This is probably a bug in the guide itself): \"[100]\"";
TestCorrelator.GetLogEventsFromCurrentContext() TestCorrelator.GetLogEventsFromCurrentContext()
.Should().ContainSingle(evt => evt.RenderMessage(default) == expectedLog); .Should().ContainSingle(evt => evt.RenderMessage(default) == expectedLog);
} }
[Test] [Test]
public void Parse_ScoreWithoutCategory_ImplicitlyPreferred() public void Parse_ScoreWithoutCategory_ImplicitlyPreferred()
{ {
var markdown = StringUtils.TrimmedString(@" var markdown = StringUtils.TrimmedString(@"
# Test Release Profile # Test Release Profile
score is [100] score is [100]
@ -302,73 +302,73 @@ score is [100]
abc abc
``` ```
"); ");
var context = new Context(); var context = new Context();
var results = context.ParseWithDefaults(markdown); var results = context.ParseWithDefaults(markdown);
results.Should() results.Should()
.ContainKey("Test Release Profile") .ContainKey("Test Release Profile")
.WhoseValue.Preferred.Should() .WhoseValue.Preferred.Should()
.BeEquivalentTo(new Dictionary<int, List<string>> .BeEquivalentTo(new Dictionary<int, List<string>>
{ {
{100, new List<string> {"abc"}} {100, new List<string> {"abc"}}
}); });
} }
[Test] [Test]
public void Parse_SkippableLines_AreSkippedWithLog() public void Parse_SkippableLines_AreSkippedWithLog()
{ {
var markdown = StringUtils.TrimmedString(@" var markdown = StringUtils.TrimmedString(@"
# First Release Profile # First Release Profile
!!! Admonition lines are skipped !!! Admonition lines are skipped
Indented lines are skipped Indented lines are skipped
"); ");
// List of substrings of logs that should appear in the resulting list of logs after parsing is done. // List of substrings of logs that should appear in the resulting list of logs after parsing is done.
// We are only looking for logs relevant to the skipped lines we're testing for. // We are only looking for logs relevant to the skipped lines we're testing for.
var expectedLogs = new List<string> var expectedLogs = new List<string>
{ {
"Skip Admonition", "Skip Admonition",
"Skip Indented Line" "Skip Indented Line"
}; };
var context = new Context(); var context = new Context();
var results = context.ParseWithDefaults(markdown); var results = context.ParseWithDefaults(markdown);
results.Should().BeEmpty(); results.Should().BeEmpty();
var ctx = TestCorrelator.GetLogEventsFromCurrentContext().ToList(); var ctx = TestCorrelator.GetLogEventsFromCurrentContext().ToList();
foreach (var log in expectedLogs) foreach (var log in expectedLogs)
{ {
ctx.Should().Contain(evt => evt.MessageTemplate.Text.Contains(log)); ctx.Should().Contain(evt => evt.MessageTemplate.Text.Contains(log));
}
} }
}
[Test] [Test]
public void Parse_StrictNegativeScores() public void Parse_StrictNegativeScores()
{
var context = new Context();
context.Config.ReleaseProfiles = new List<ReleaseProfileConfig>
{ {
var context = new Context(); new() {StrictNegativeScores = true}
context.Config.ReleaseProfiles = new List<ReleaseProfileConfig> };
{
new() {StrictNegativeScores = true}
};
var markdown = context.TestData.ReadData("strict_negative_scores.md"); var markdown = context.TestData.ReadData("strict_negative_scores.md");
var results = context.ParseWithDefaults(markdown); var results = context.ParseWithDefaults(markdown);
results.Should() results.Should()
.ContainKey("Test Release Profile").WhoseValue.Should() .ContainKey("Test Release Profile").WhoseValue.Should()
.BeEquivalentTo(new .BeEquivalentTo(new
{ {
Required = new { }, Required = new { },
Ignored = new List<string> {"abc"}, Ignored = new List<string> {"abc"},
Preferred = new Dictionary<int, List<string>> {{0, new List<string> {"xyz"}}} Preferred = new Dictionary<int, List<string>> {{0, new List<string> {"xyz"}}}
}); });
} }
[Test] [Test]
public void Parse_TermsWithoutCategory_AreSkipped() public void Parse_TermsWithoutCategory_AreSkipped()
{ {
var markdown = StringUtils.TrimmedString(@" var markdown = StringUtils.TrimmedString(@"
# Test Release Profile # Test Release Profile
``` ```
@ -401,24 +401,23 @@ added3
skipped2 skipped2
``` ```
"); ");
var context = new Context(); var context = new Context();
var results = context.ParseWithDefaults(markdown); var results = context.ParseWithDefaults(markdown);
var expectedResults = new Dictionary<string, ProfileData> var expectedResults = new Dictionary<string, ProfileData>
{
{ {
"Test Release Profile", new ProfileData
{ {
"Test Release Profile", new ProfileData Ignored = new List<string> {"added1"},
Preferred = new Dictionary<int, List<string>>
{ {
Ignored = new List<string> {"added1"}, {10, new List<string> {"added2", "added3"}}
Preferred = new Dictionary<int, List<string>>
{
{10, new List<string> {"added2", "added3"}}
}
} }
} }
}; }
};
results.Should().BeEquivalentTo(expectedResults); results.Should().BeEquivalentTo(expectedResults);
}
} }
} }

@ -2,130 +2,129 @@
using NUnit.Framework; using NUnit.Framework;
using TrashLib.Sonarr.ReleaseProfile; using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr.ReleaseProfile namespace TrashLib.Tests.Sonarr.ReleaseProfile;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ScopedStateTest
{ {
[TestFixture] [Test]
[Parallelizable(ParallelScope.All)] public void AccessValue_MultipleScopes_ScopeValuesReturned()
public class ScopedStateTest {
var state = new ScopedState<int>(50);
state.PushValue(100, 0);
state.PushValue(150, 1);
state.StackSize.Should().Be(2);
state.ActiveScope.Should().Be(1);
state.Value.Should().Be(150);
state.Reset(1).Should().BeTrue();
state.StackSize.Should().Be(1);
state.ActiveScope.Should().Be(0);
state.Value.Should().Be(100);
state.Reset(0).Should().BeTrue();
state.StackSize.Should().Be(0);
state.ActiveScope.Should().BeNull();
state.Value.Should().Be(50);
}
[Test]
public void AccessValue_NextBlockScope_ReturnValueUntilSecondSession()
{
var state = new ScopedState<int>(50);
state.PushValue(100, 0);
state.ActiveScope.Should().Be(0);
state.Value.Should().Be(100);
state.Reset(0).Should().BeTrue();
state.ActiveScope.Should().BeNull();
state.Value.Should().Be(50);
}
[Test]
public void AccessValue_NoScope_ReturnDefaultValue()
{
var state = new ScopedState<int>(50);
state.ActiveScope.Should().BeNull();
state.Value.Should().Be(50);
}
[Test]
public void AccessValue_ResetAfterScope_ReturnDefault()
{
var state = new ScopedState<int>(50);
state.PushValue(100, 1);
state.Reset(1).Should().BeTrue();
state.ActiveScope.Should().BeNull();
state.Value.Should().Be(50);
}
[Test]
public void AccessValue_WholeSectionScope_ReturnValueAcrossMultipleResets()
{ {
[Test] var state = new ScopedState<int>(50);
public void AccessValue_MultipleScopes_ScopeValuesReturned() state.PushValue(100, 1);
{
var state = new ScopedState<int>(50); state.ActiveScope.Should().Be(1);
state.PushValue(100, 0); state.Value.Should().Be(100);
state.PushValue(150, 1);
state.Reset(2).Should().BeFalse();
state.StackSize.Should().Be(2);
state.ActiveScope.Should().Be(1); state.ActiveScope.Should().Be(1);
state.Value.Should().Be(150); state.Value.Should().Be(100);
}
state.Reset(1).Should().BeTrue();
[Test]
state.StackSize.Should().Be(1); public void Reset_UsingGreatestScopeWithTwoScopes_ShouldRemoveAllScope()
state.ActiveScope.Should().Be(0); {
state.Value.Should().Be(100); var state = new ScopedState<int>(50);
state.PushValue(100, 1);
state.Reset(0).Should().BeTrue(); state.PushValue(150, 0);
state.Reset(1).Should().BeTrue();
state.StackSize.Should().Be(0);
state.ActiveScope.Should().BeNull(); state.ActiveScope.Should().BeNull();
state.Value.Should().Be(50); state.Value.Should().Be(50);
} }
[Test] [Test]
public void AccessValue_NextBlockScope_ReturnValueUntilSecondSession() public void Reset_UsingLesserScopeWithTwoScopes_ShouldRemoveTopScope()
{ {
var state = new ScopedState<int>(50); var state = new ScopedState<int>(50);
state.PushValue(100, 0); state.PushValue(100, 0);
state.PushValue(150, 1);
state.ActiveScope.Should().Be(0); state.Reset(1).Should().BeTrue();
state.Value.Should().Be(100);
state.ActiveScope.Should().Be(0);
state.Reset(0).Should().BeTrue(); state.Value.Should().Be(100);
}
state.ActiveScope.Should().BeNull();
state.Value.Should().Be(50); [Test]
} public void Reset_WithLesserScope_ShouldDoNothing()
{
[Test] var state = new ScopedState<int>(50);
public void AccessValue_NoScope_ReturnDefaultValue() state.PushValue(100, 1);
{ state.Reset(2).Should().BeFalse();
var state = new ScopedState<int>(50);
state.ActiveScope.Should().BeNull(); state.ActiveScope.Should().Be(1);
state.Value.Should().Be(50); state.Value.Should().Be(100);
} }
[Test] [Test]
public void AccessValue_ResetAfterScope_ReturnDefault() public void Reset_WithScope_ShouldReset()
{ {
var state = new ScopedState<int>(50); var state = new ScopedState<int>(50);
state.PushValue(100, 1); state.PushValue(100, 1);
state.Reset(1).Should().BeTrue();
state.Reset(1).Should().BeTrue();
state.ActiveScope.Should().BeNull();
state.ActiveScope.Should().BeNull(); state.Value.Should().Be(50);
state.Value.Should().Be(50);
}
[Test]
public void AccessValue_WholeSectionScope_ReturnValueAcrossMultipleResets()
{
var state = new ScopedState<int>(50);
state.PushValue(100, 1);
state.ActiveScope.Should().Be(1);
state.Value.Should().Be(100);
state.Reset(2).Should().BeFalse();
state.ActiveScope.Should().Be(1);
state.Value.Should().Be(100);
}
[Test]
public void Reset_UsingGreatestScopeWithTwoScopes_ShouldRemoveAllScope()
{
var state = new ScopedState<int>(50);
state.PushValue(100, 1);
state.PushValue(150, 0);
state.Reset(1).Should().BeTrue();
state.ActiveScope.Should().BeNull();
state.Value.Should().Be(50);
}
[Test]
public void Reset_UsingLesserScopeWithTwoScopes_ShouldRemoveTopScope()
{
var state = new ScopedState<int>(50);
state.PushValue(100, 0);
state.PushValue(150, 1);
state.Reset(1).Should().BeTrue();
state.ActiveScope.Should().Be(0);
state.Value.Should().Be(100);
}
[Test]
public void Reset_WithLesserScope_ShouldDoNothing()
{
var state = new ScopedState<int>(50);
state.PushValue(100, 1);
state.Reset(2).Should().BeFalse();
state.ActiveScope.Should().Be(1);
state.Value.Should().Be(100);
}
[Test]
public void Reset_WithScope_ShouldReset()
{
var state = new ScopedState<int>(50);
state.PushValue(100, 1);
state.Reset(1).Should().BeTrue();
state.ActiveScope.Should().BeNull();
state.Value.Should().Be(50);
}
} }
} }

@ -6,46 +6,45 @@ using TrashLib.Sonarr.Api;
using TrashLib.Sonarr.Config; using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile; using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr namespace TrashLib.Tests.Sonarr;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ReleaseProfileUpdaterTest
{ {
[TestFixture] private class Context
[Parallelizable(ParallelScope.All)]
public class ReleaseProfileUpdaterTest
{ {
private class Context public IReleaseProfileGuideParser Parser { get; } = Substitute.For<IReleaseProfileGuideParser>();
{ public ISonarrApi Api { get; } = Substitute.For<ISonarrApi>();
public IReleaseProfileGuideParser Parser { get; } = Substitute.For<IReleaseProfileGuideParser>(); public ILogger Logger { get; } = Substitute.For<ILogger>();
public ISonarrApi Api { get; } = Substitute.For<ISonarrApi>(); public ISonarrCompatibility Compatibility { get; } = Substitute.For<ISonarrCompatibility>();
public ILogger Logger { get; } = Substitute.For<ILogger>(); }
public ISonarrCompatibility Compatibility { get; } = Substitute.For<ISonarrCompatibility>();
} [Test]
public void ProcessReleaseProfile_InvalidReleaseProfiles_NoCrashNoCalls()
[Test] {
public void ProcessReleaseProfile_InvalidReleaseProfiles_NoCrashNoCalls() var context = new Context();
{
var context = new Context();
var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api, context.Compatibility); var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api, context.Compatibility);
logic.Process(false, new SonarrConfiguration()); logic.Process(false, new SonarrConfiguration());
context.Parser.DidNotReceive().GetMarkdownData(Arg.Any<ReleaseProfileType>()); context.Parser.DidNotReceive().GetMarkdownData(Arg.Any<ReleaseProfileType>());
} }
[Test] [Test]
public void ProcessReleaseProfile_SingleProfilePreview() public void ProcessReleaseProfile_SingleProfilePreview()
{ {
var context = new Context(); var context = new Context();
context.Parser.GetMarkdownData(ReleaseProfileType.Anime).Returns("theMarkdown"); context.Parser.GetMarkdownData(ReleaseProfileType.Anime).Returns("theMarkdown");
var config = new SonarrConfiguration var config = new SonarrConfiguration
{ {
ReleaseProfiles = new[] {new ReleaseProfileConfig {Type = ReleaseProfileType.Anime}} ReleaseProfiles = new[] {new ReleaseProfileConfig {Type = ReleaseProfileType.Anime}}
}; };
var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api, context.Compatibility); var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api, context.Compatibility);
logic.Process(false, config); logic.Process(false, config);
context.Parser.Received().ParseMarkdown(config.ReleaseProfiles[0], "theMarkdown"); context.Parser.Received().ParseMarkdown(config.ReleaseProfiles[0], "theMarkdown");
}
} }
} }

@ -9,62 +9,61 @@ using TrashLib.Sonarr;
using TrashLib.Sonarr.Config; using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile; using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr namespace TrashLib.Tests.Sonarr;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrConfigurationTest
{ {
[TestFixture] private IContainer _container = default!;
[Parallelizable(ParallelScope.All)]
public class SonarrConfigurationTest
{
private IContainer _container = default!;
[OneTimeSetUp] [OneTimeSetUp]
public void Setup() public void Setup()
{ {
var builder = new ContainerBuilder(); var builder = new ContainerBuilder();
builder.RegisterModule<ConfigAutofacModule>(); builder.RegisterModule<ConfigAutofacModule>();
builder.RegisterModule<SonarrAutofacModule>(); builder.RegisterModule<SonarrAutofacModule>();
_container = builder.Build(); _container = builder.Build();
} }
[Test] [Test]
public void Validation_fails_for_all_missing_required_properties() public void Validation_fails_for_all_missing_required_properties()
{ {
// default construct which should yield default values (invalid) for all required properties // default construct which should yield default values (invalid) for all required properties
var config = new SonarrConfiguration(); var config = new SonarrConfiguration();
var validator = _container.Resolve<IValidator<SonarrConfiguration>>(); var validator = _container.Resolve<IValidator<SonarrConfiguration>>();
var result = validator.Validate(config); var result = validator.Validate(config);
var expectedErrorMessageSubstrings = new[] var expectedErrorMessageSubstrings = new[]
{ {
"Property 'base_url' is required", "Property 'base_url' is required",
"Property 'api_key' is required", "Property 'api_key' is required",
"'type' is required for 'release_profiles' elements" "'type' is required for 'release_profiles' elements"
}; };
result.IsValid.Should().BeFalse(); result.IsValid.Should().BeFalse();
result.Errors.Select(e => e.ErrorMessage).Should() result.Errors.Select(e => e.ErrorMessage).Should()
.OnlyContain(x => expectedErrorMessageSubstrings.Any(x.Contains)); .OnlyContain(x => expectedErrorMessageSubstrings.Any(x.Contains));
} }
[Test] [Test]
public void Validation_succeeds_when_no_missing_required_properties() public void Validation_succeeds_when_no_missing_required_properties()
{
var config = new SonarrConfiguration
{ {
var config = new SonarrConfiguration ApiKey = "required value",
BaseUrl = "required value",
ReleaseProfiles = new List<ReleaseProfileConfig>
{ {
ApiKey = "required value", new() {Type = ReleaseProfileType.Anime}
BaseUrl = "required value", }
ReleaseProfiles = new List<ReleaseProfileConfig> };
{
new() {Type = ReleaseProfileType.Anime}
}
};
var validator = _container.Resolve<IValidator<SonarrConfiguration>>(); var validator = _container.Resolve<IValidator<SonarrConfiguration>>();
var result = validator.Validate(config); var result = validator.Validate(config);
result.IsValid.Should().BeTrue(); result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty(); result.Errors.Should().BeEmpty();
}
} }
} }

@ -1,13 +1,12 @@
using Autofac; using Autofac;
namespace TrashLib.Cache namespace TrashLib.Cache;
public class CacheAutofacModule : Module
{ {
public class CacheAutofacModule : Module protected override void Load(ContainerBuilder builder)
{ {
protected override void Load(ContainerBuilder builder) // Clients must register their own implementation of ICacheStoragePath
{ builder.RegisterType<ServiceCache>().As<IServiceCache>();
// Clients must register their own implementation of ICacheStoragePath
builder.RegisterType<ServiceCache>().As<IServiceCache>();
}
} }
} }

@ -1,15 +1,14 @@
using System; using System;
namespace TrashLib.Cache namespace TrashLib.Cache;
[AttributeUsage(AttributeTargets.Class)]
internal sealed class CacheObjectNameAttribute : Attribute
{ {
[AttributeUsage(AttributeTargets.Class)] public CacheObjectNameAttribute(string name)
internal sealed class CacheObjectNameAttribute : Attribute
{ {
public CacheObjectNameAttribute(string name) Name = name;
{
Name = name;
}
public string Name { get; }
} }
public string Name { get; }
} }

@ -1,7 +1,6 @@
namespace TrashLib.Cache namespace TrashLib.Cache;
public interface ICacheStoragePath
{ {
public interface ICacheStoragePath string Path { get; }
{
string Path { get; }
}
} }

@ -1,8 +1,7 @@
namespace TrashLib.Cache namespace TrashLib.Cache;
public interface IServiceCache
{ {
public interface IServiceCache T? Load<T>() where T : class;
{ void Save<T>(T obj) where T : class;
T? Load<T>() where T : class;
void Save<T>(T obj) where T : class;
}
} }

@ -11,91 +11,90 @@ using Newtonsoft.Json.Serialization;
using Serilog; using Serilog;
using TrashLib.Config; using TrashLib.Config;
namespace TrashLib.Cache namespace TrashLib.Cache;
internal class ServiceCache : IServiceCache
{ {
internal class ServiceCache : IServiceCache private static readonly Regex AllowedObjectNameCharacters = new(@"^[\w-]+$", RegexOptions.Compiled);
private readonly IConfigurationProvider _configProvider;
private readonly IFileSystem _fileSystem;
private readonly IFNV1a _hash;
private readonly ICacheStoragePath _storagePath;
public ServiceCache(IFileSystem fileSystem, ICacheStoragePath storagePath,
IConfigurationProvider configProvider,
ILogger log)
{ {
private static readonly Regex AllowedObjectNameCharacters = new(@"^[\w-]+$", RegexOptions.Compiled); _fileSystem = fileSystem;
private readonly IConfigurationProvider _configProvider; _storagePath = storagePath;
private readonly IFileSystem _fileSystem; _configProvider = configProvider;
private readonly IFNV1a _hash; Log = log;
private readonly ICacheStoragePath _storagePath; _hash = FNV1aFactory.Instance.Create(FNVConfig.GetPredefinedConfig(32));
}
public ServiceCache(IFileSystem fileSystem, ICacheStoragePath storagePath, private ILogger Log { get; }
IConfigurationProvider configProvider,
ILogger log) public T? Load<T>() where T : class
{
var path = PathFromAttribute<T>();
if (!_fileSystem.File.Exists(path))
{ {
_fileSystem = fileSystem; return null;
_storagePath = storagePath;
_configProvider = configProvider;
Log = log;
_hash = FNV1aFactory.Instance.Create(FNVConfig.GetPredefinedConfig(32));
} }
private ILogger Log { get; } var json = _fileSystem.File.ReadAllText(path);
public T? Load<T>() where T : class try
{ {
var path = PathFromAttribute<T>(); return JObject.Parse(json).ToObject<T>();
if (!_fileSystem.File.Exists(path))
{
return null;
}
var json = _fileSystem.File.ReadAllText(path);
try
{
return JObject.Parse(json).ToObject<T>();
}
catch (JsonException e)
{
Log.Error("Failed to read cache data, will proceed without cache. Reason: {Msg}", e.Message);
}
return null;
} }
catch (JsonException e)
public void Save<T>(T obj) where T : class
{ {
var path = PathFromAttribute<T>(); Log.Error("Failed to read cache data, will proceed without cache. Reason: {Msg}", e.Message);
_fileSystem.Directory.CreateDirectory(Path.GetDirectoryName(path));
_fileSystem.File.WriteAllText(path, JsonConvert.SerializeObject(obj, new JsonSerializerSettings
{
Formatting = Formatting.Indented,
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new SnakeCaseNamingStrategy()
}
}));
} }
private static string GetCacheObjectNameAttribute<T>() return null;
}
public void Save<T>(T obj) where T : class
{
var path = PathFromAttribute<T>();
_fileSystem.Directory.CreateDirectory(Path.GetDirectoryName(path));
_fileSystem.File.WriteAllText(path, JsonConvert.SerializeObject(obj, new JsonSerializerSettings
{ {
var attribute = typeof(T).GetCustomAttribute<CacheObjectNameAttribute>(); Formatting = Formatting.Indented,
if (attribute == null) ContractResolver = new DefaultContractResolver
{ {
throw new ArgumentException($"{nameof(CacheObjectNameAttribute)} is missing on type {nameof(T)}"); NamingStrategy = new SnakeCaseNamingStrategy()
} }
}));
}
return attribute.Name; private static string GetCacheObjectNameAttribute<T>()
} {
var attribute = typeof(T).GetCustomAttribute<CacheObjectNameAttribute>();
private string BuildServiceGuid() if (attribute == null)
{ {
return _hash.ComputeHash(Encoding.ASCII.GetBytes(_configProvider.ActiveConfiguration.BaseUrl)) throw new ArgumentException($"{nameof(CacheObjectNameAttribute)} is missing on type {nameof(T)}");
.AsHexString();
} }
private string PathFromAttribute<T>() return attribute.Name;
{ }
var objectName = GetCacheObjectNameAttribute<T>();
if (!AllowedObjectNameCharacters.IsMatch(objectName)) private string BuildServiceGuid()
{ {
throw new ArgumentException($"Object name '{objectName}' has unacceptable characters"); return _hash.ComputeHash(Encoding.ASCII.GetBytes(_configProvider.ActiveConfiguration.BaseUrl))
} .AsHexString();
}
return Path.Combine(_storagePath.Path, BuildServiceGuid(), objectName + ".json"); private string PathFromAttribute<T>()
{
var objectName = GetCacheObjectNameAttribute<T>();
if (!AllowedObjectNameCharacters.IsMatch(objectName))
{
throw new ArgumentException($"Object name '{objectName}' has unacceptable characters");
} }
return Path.Combine(_storagePath.Path, BuildServiceGuid(), objectName + ".json");
} }
} }

@ -3,19 +3,18 @@ using Autofac;
using FluentValidation; using FluentValidation;
using Module = Autofac.Module; using Module = Autofac.Module;
namespace TrashLib.Config namespace TrashLib.Config;
public class ConfigAutofacModule : Module
{ {
public class ConfigAutofacModule : Module protected override void Load(ContainerBuilder builder)
{ {
protected override void Load(ContainerBuilder builder) builder.RegisterType<ConfigurationProvider>()
{ .As<IConfigurationProvider>()
builder.RegisterType<ConfigurationProvider>() .SingleInstance();
.As<IConfigurationProvider>()
.SingleInstance();
builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly()) builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
.AsClosedTypesOf(typeof(IValidator<>)) .AsClosedTypesOf(typeof(IValidator<>))
.AsImplementedInterfaces(); .AsImplementedInterfaces();
}
} }
} }

@ -1,15 +1,14 @@
using System; using System;
namespace TrashLib.Config namespace TrashLib.Config;
internal class ConfigurationProvider : IConfigurationProvider
{ {
internal class ConfigurationProvider : IConfigurationProvider private IServiceConfiguration? _activeConfiguration;
{
private IServiceConfiguration? _activeConfiguration;
public IServiceConfiguration ActiveConfiguration public IServiceConfiguration ActiveConfiguration
{ {
get => _activeConfiguration ?? throw new NullReferenceException("Active configuration has not been set"); get => _activeConfiguration ?? throw new NullReferenceException("Active configuration has not been set");
set => _activeConfiguration = value; set => _activeConfiguration = value;
}
} }
} }

@ -1,7 +1,6 @@
namespace TrashLib.Config namespace TrashLib.Config;
public interface IConfigurationProvider
{ {
public interface IConfigurationProvider IServiceConfiguration ActiveConfiguration { get; set; }
{
IServiceConfiguration ActiveConfiguration { get; set; }
}
} }

@ -1,9 +1,8 @@
using Flurl.Http; using Flurl.Http;
namespace TrashLib.Config namespace TrashLib.Config;
public interface IServerInfo
{ {
public interface IServerInfo IFlurlRequest BuildRequest();
{
IFlurlRequest BuildRequest();
}
} }

@ -1,8 +1,7 @@
namespace TrashLib.Config namespace TrashLib.Config;
public interface IServiceConfiguration
{ {
public interface IServiceConfiguration string BaseUrl { get; }
{ string ApiKey { get; }
string BaseUrl { get; }
string ApiKey { get; }
}
} }

@ -3,28 +3,27 @@ using Flurl.Http;
using Serilog; using Serilog;
using TrashLib.Extensions; using TrashLib.Extensions;
namespace TrashLib.Config namespace TrashLib.Config;
internal class ServerInfo : IServerInfo
{ {
internal class ServerInfo : IServerInfo private readonly IConfigurationProvider _config;
{ private readonly ILogger _log;
private readonly IConfigurationProvider _config;
private readonly ILogger _log;
public ServerInfo(IConfigurationProvider config, ILogger log) public ServerInfo(IConfigurationProvider config, ILogger log)
{ {
_config = config; _config = config;
_log = log; _log = log;
} }
public IFlurlRequest BuildRequest() public IFlurlRequest BuildRequest()
{ {
var apiKey = _config.ActiveConfiguration.ApiKey; var apiKey = _config.ActiveConfiguration.ApiKey;
var baseUrl = _config.ActiveConfiguration.BaseUrl; var baseUrl = _config.ActiveConfiguration.BaseUrl;
return baseUrl return baseUrl
.AppendPathSegment("api/v3") .AppendPathSegment("api/v3")
.SetQueryParams(new {apikey = apiKey}) .SetQueryParams(new {apikey = apiKey})
.SanitizedLogging(_log); .SanitizedLogging(_log);
}
} }
} }

@ -1,8 +1,7 @@
namespace TrashLib.Config namespace TrashLib.Config;
public abstract class ServiceConfiguration : IServiceConfiguration
{ {
public abstract class ServiceConfiguration : IServiceConfiguration public string BaseUrl { get; init; } = "";
{ public string ApiKey { get; init; } = "";
public string BaseUrl { get; init; } = "";
public string ApiKey { get; init; } = "";
}
} }

@ -1,19 +1,18 @@
using System; using System;
using System.Runtime.Serialization; using System.Runtime.Serialization;
namespace TrashLib.ExceptionTypes namespace TrashLib.ExceptionTypes;
[Serializable]
public class VersionException : Exception
{ {
[Serializable] public VersionException(string msg)
public class VersionException : Exception : base(msg)
{ {
public VersionException(string msg) }
: base(msg)
{
}
protected VersionException(SerializationInfo info, StreamingContext context) protected VersionException(SerializationInfo info, StreamingContext context)
: base(info, context) : base(info, context)
{ {
}
} }
} }

@ -3,34 +3,33 @@ using Flurl;
using Flurl.Http; using Flurl.Http;
using Serilog; using Serilog;
namespace TrashLib.Extensions namespace TrashLib.Extensions;
public static class FlurlExtensions
{ {
public static class FlurlExtensions public static IFlurlRequest SanitizedLogging(this Uri url, ILogger log)
{ => new FlurlRequest(url).SanitizedLogging(log);
public static IFlurlRequest SanitizedLogging(this Uri url, ILogger log)
=> new FlurlRequest(url).SanitizedLogging(log);
public static IFlurlRequest SanitizedLogging(this Url url, ILogger log) public static IFlurlRequest SanitizedLogging(this Url url, ILogger log)
=> new FlurlRequest(url).SanitizedLogging(log); => new FlurlRequest(url).SanitizedLogging(log);
public static IFlurlRequest SanitizedLogging(this string url, ILogger log) public static IFlurlRequest SanitizedLogging(this string url, ILogger log)
=> new FlurlRequest(url).SanitizedLogging(log); => new FlurlRequest(url).SanitizedLogging(log);
public static IFlurlRequest SanitizedLogging(this IFlurlRequest request, ILogger log) public static IFlurlRequest SanitizedLogging(this IFlurlRequest request, ILogger log)
{ {
return request.ConfigureRequest(settings => FlurlLogging.SetupLogging(settings, log, SanitizeUrl)); return request.ConfigureRequest(settings => FlurlLogging.SetupLogging(settings, log, SanitizeUrl));
} }
private static Url SanitizeUrl(Url url) private static Url SanitizeUrl(Url url)
{
// Replace hostname and API key for user privacy
url.Host = "hostname";
if (url.QueryParams.Contains("apikey"))
{ {
// Replace hostname and API key for user privacy url.QueryParams.AddOrReplace("apikey", "SNIP");
url.Host = "hostname";
if (url.QueryParams.Contains("apikey"))
{
url.QueryParams.AddOrReplace("apikey", "SNIP");
}
return url;
} }
return url;
} }
} }

@ -3,26 +3,25 @@ using Flurl;
using Flurl.Http.Configuration; using Flurl.Http.Configuration;
using Serilog; using Serilog;
namespace TrashLib.Extensions namespace TrashLib.Extensions;
public static class FlurlLogging
{ {
public static class FlurlLogging public static void SetupLogging(FlurlHttpSettings settings, ILogger log, Func<Url, Url>? urlInterceptor = null)
{ {
public static void SetupLogging(FlurlHttpSettings settings, ILogger log, Func<Url, Url>? urlInterceptor = null) urlInterceptor ??= url => url;
{
urlInterceptor ??= url => url;
settings.BeforeCall = call => settings.BeforeCall = call =>
{ {
var url = urlInterceptor(call.Request.Url.Clone()); var url = urlInterceptor(call.Request.Url.Clone());
log.Debug("HTTP Request to {Url}", url); log.Debug("HTTP Request to {Url}", url);
}; };
settings.AfterCall = call => settings.AfterCall = call =>
{ {
var statusCode = call.Response?.StatusCode.ToString() ?? "(No response)"; var statusCode = call.Response?.StatusCode.ToString() ?? "(No response)";
var url = urlInterceptor(call.Request.Url.Clone()); var url = urlInterceptor(call.Request.Url.Clone());
log.Debug("HTTP Response {Status} from {Url}", statusCode, url); log.Debug("HTTP Response {Status} from {Url}", statusCode, url);
}; };
}
} }
} }

@ -1,11 +1,10 @@
namespace TrashLib.Radarr.Config namespace TrashLib.Radarr.Config;
public interface IRadarrValidationMessages
{ {
public interface IRadarrValidationMessages string BaseUrl { get; }
{ string ApiKey { get; }
string BaseUrl { get; } string CustomFormatNamesAndIds { get; }
string ApiKey { get; } string QualityProfileName { get; }
string CustomFormatNamesAndIds { get; } string QualityDefinitionType { get; }
string QualityProfileName { get; }
string QualityDefinitionType { get; }
}
} }

@ -1,7 +1,6 @@
namespace TrashLib.Radarr.Config namespace TrashLib.Radarr.Config;
public interface IResourcePaths
{ {
public interface IResourcePaths string RepoPath { get; }
{
string RepoPath { get; }
}
} }

@ -3,39 +3,38 @@ using JetBrains.Annotations;
using TrashLib.Config; using TrashLib.Config;
using TrashLib.Radarr.QualityDefinition; using TrashLib.Radarr.QualityDefinition;
namespace TrashLib.Radarr.Config namespace TrashLib.Radarr.Config;
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class RadarrConfiguration : ServiceConfiguration
{ {
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public QualityDefinitionConfig? QualityDefinition { get; init; }
public class RadarrConfiguration : ServiceConfiguration public ICollection<CustomFormatConfig> CustomFormats { get; init; } = new List<CustomFormatConfig>();
{ public bool DeleteOldCustomFormats { get; init; }
public QualityDefinitionConfig? QualityDefinition { get; init; } }
public ICollection<CustomFormatConfig> CustomFormats { get; init; } = new List<CustomFormatConfig>();
public bool DeleteOldCustomFormats { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class CustomFormatConfig public class CustomFormatConfig
{ {
public ICollection<string> Names { get; init; } = new List<string>(); public ICollection<string> Names { get; init; } = new List<string>();
public ICollection<string> TrashIds { get; init; } = new List<string>(); public ICollection<string> TrashIds { get; init; } = new List<string>();
public ICollection<QualityProfileConfig> QualityProfiles { get; init; } = new List<QualityProfileConfig>(); public ICollection<QualityProfileConfig> QualityProfiles { get; init; } = new List<QualityProfileConfig>();
} }
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class QualityProfileConfig public class QualityProfileConfig
{ {
public string Name { get; init; } = ""; public string Name { get; init; } = "";
public int? Score { get; init; } public int? Score { get; init; }
public bool ResetUnmatchedScores { get; init; } public bool ResetUnmatchedScores { get; init; }
} }
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class QualityDefinitionConfig public class QualityDefinitionConfig
{ {
// -1 does not map to a valid enumerator. this is to force validation to fail if it is not set from YAML. // -1 does not map to a valid enumerator. this is to force validation to fail if it is not set from YAML.
// All of this craziness is to avoid making the enum type nullable. // All of this craziness is to avoid making the enum type nullable.
public RadarrQualityDefinitionType Type { get; init; } = (RadarrQualityDefinitionType) (-1); public RadarrQualityDefinitionType Type { get; init; } = (RadarrQualityDefinitionType) (-1);
public decimal PreferredRatio { get; set; } = 1.0m; public decimal PreferredRatio { get; set; } = 1.0m;
}
} }

@ -2,51 +2,50 @@ using Common.Extensions;
using FluentValidation; using FluentValidation;
using JetBrains.Annotations; using JetBrains.Annotations;
namespace TrashLib.Radarr.Config namespace TrashLib.Radarr.Config;
[UsedImplicitly]
internal class RadarrConfigurationValidator : AbstractValidator<RadarrConfiguration>
{ {
[UsedImplicitly] public RadarrConfigurationValidator(
internal class RadarrConfigurationValidator : AbstractValidator<RadarrConfiguration> IRadarrValidationMessages messages,
IValidator<QualityDefinitionConfig> qualityDefinitionConfigValidator,
IValidator<CustomFormatConfig> customFormatConfigValidator)
{ {
public RadarrConfigurationValidator( RuleFor(x => x.BaseUrl).NotEmpty().WithMessage(messages.BaseUrl);
IRadarrValidationMessages messages, RuleFor(x => x.ApiKey).NotEmpty().WithMessage(messages.ApiKey);
IValidator<QualityDefinitionConfig> qualityDefinitionConfigValidator, RuleFor(x => x.QualityDefinition).SetNonNullableValidator(qualityDefinitionConfigValidator);
IValidator<CustomFormatConfig> customFormatConfigValidator) RuleForEach(x => x.CustomFormats).SetValidator(customFormatConfigValidator);
{
RuleFor(x => x.BaseUrl).NotEmpty().WithMessage(messages.BaseUrl);
RuleFor(x => x.ApiKey).NotEmpty().WithMessage(messages.ApiKey);
RuleFor(x => x.QualityDefinition).SetNonNullableValidator(qualityDefinitionConfigValidator);
RuleForEach(x => x.CustomFormats).SetValidator(customFormatConfigValidator);
}
} }
}
[UsedImplicitly] [UsedImplicitly]
internal class CustomFormatConfigValidator : AbstractValidator<CustomFormatConfig> internal class CustomFormatConfigValidator : AbstractValidator<CustomFormatConfig>
{
public CustomFormatConfigValidator(
IRadarrValidationMessages messages,
IValidator<QualityProfileConfig> qualityProfileConfigValidator)
{ {
public CustomFormatConfigValidator( RuleFor(x => x.Names).NotEmpty().When(x => x.TrashIds.Count == 0)
IRadarrValidationMessages messages, .WithMessage(messages.CustomFormatNamesAndIds);
IValidator<QualityProfileConfig> qualityProfileConfigValidator) RuleForEach(x => x.QualityProfiles).SetValidator(qualityProfileConfigValidator);
{
RuleFor(x => x.Names).NotEmpty().When(x => x.TrashIds.Count == 0)
.WithMessage(messages.CustomFormatNamesAndIds);
RuleForEach(x => x.QualityProfiles).SetValidator(qualityProfileConfigValidator);
}
} }
}
[UsedImplicitly] [UsedImplicitly]
internal class QualityProfileConfigValidator : AbstractValidator<QualityProfileConfig> internal class QualityProfileConfigValidator : AbstractValidator<QualityProfileConfig>
{
public QualityProfileConfigValidator(IRadarrValidationMessages messages)
{ {
public QualityProfileConfigValidator(IRadarrValidationMessages messages) RuleFor(x => x.Name).NotEmpty().WithMessage(messages.QualityProfileName);
{
RuleFor(x => x.Name).NotEmpty().WithMessage(messages.QualityProfileName);
}
} }
}
[UsedImplicitly] [UsedImplicitly]
internal class QualityDefinitionConfigValidator : AbstractValidator<QualityDefinitionConfig> internal class QualityDefinitionConfigValidator : AbstractValidator<QualityDefinitionConfig>
{
public QualityDefinitionConfigValidator(IRadarrValidationMessages messages)
{ {
public QualityDefinitionConfigValidator(IRadarrValidationMessages messages) RuleFor(x => x.Type).IsInEnum().WithMessage(messages.QualityDefinitionType);
{
RuleFor(x => x.Type).IsInEnum().WithMessage(messages.QualityDefinitionType);
}
} }
} }

@ -1,20 +1,19 @@
namespace TrashLib.Radarr.Config namespace TrashLib.Radarr.Config;
internal class RadarrValidationMessages : IRadarrValidationMessages
{ {
internal class RadarrValidationMessages : IRadarrValidationMessages public string BaseUrl =>
{ "Property 'base_url' is required";
public string BaseUrl =>
"Property 'base_url' is required";
public string ApiKey => public string ApiKey =>
"Property 'api_key' is required"; "Property 'api_key' is required";
public string CustomFormatNamesAndIds => public string CustomFormatNamesAndIds =>
"'custom_formats' elements must contain at least one element in either 'names' or 'trash_ids'"; "'custom_formats' elements must contain at least one element in either 'names' or 'trash_ids'";
public string QualityProfileName => public string QualityProfileName =>
"'name' is required for elements under 'quality_profiles'"; "'name' is required for elements under 'quality_profiles'";
public string QualityDefinitionType => public string QualityDefinitionType =>
"'type' is required for 'quality_definition'"; "'type' is required for 'quality_definition'";
}
} }

@ -5,52 +5,51 @@ using Newtonsoft.Json.Linq;
using TrashLib.Config; using TrashLib.Config;
using TrashLib.Radarr.CustomFormat.Models; using TrashLib.Radarr.CustomFormat.Models;
namespace TrashLib.Radarr.CustomFormat.Api namespace TrashLib.Radarr.CustomFormat.Api;
internal class CustomFormatService : ICustomFormatService
{ {
internal class CustomFormatService : ICustomFormatService private readonly IServerInfo _serverInfo;
{
private readonly IServerInfo _serverInfo;
public CustomFormatService(IServerInfo serverInfo) public CustomFormatService(IServerInfo serverInfo)
{ {
_serverInfo = serverInfo; _serverInfo = serverInfo;
} }
public async Task<List<JObject>> GetCustomFormats() public async Task<List<JObject>> GetCustomFormats()
{ {
return await BuildRequest() return await BuildRequest()
.AppendPathSegment("customformat") .AppendPathSegment("customformat")
.GetJsonAsync<List<JObject>>(); .GetJsonAsync<List<JObject>>();
} }
public async Task CreateCustomFormat(ProcessedCustomFormatData cf) public async Task CreateCustomFormat(ProcessedCustomFormatData cf)
{ {
var response = await BuildRequest() var response = await BuildRequest()
.AppendPathSegment("customformat") .AppendPathSegment("customformat")
.PostJsonAsync(cf.Json) .PostJsonAsync(cf.Json)
.ReceiveJson<JObject>(); .ReceiveJson<JObject>();
if (response != null)
{
cf.SetCache(response.Value<int>("id"));
}
}
public async Task UpdateCustomFormat(ProcessedCustomFormatData cf) if (response != null)
{ {
await BuildRequest() cf.SetCache(response.Value<int>("id"));
.AppendPathSegment($"customformat/{cf.GetCustomFormatId()}")
.PutJsonAsync(cf.Json)
.ReceiveJson<JObject>();
} }
}
public async Task DeleteCustomFormat(int customFormatId) public async Task UpdateCustomFormat(ProcessedCustomFormatData cf)
{ {
await BuildRequest() await BuildRequest()
.AppendPathSegment($"customformat/{customFormatId}") .AppendPathSegment($"customformat/{cf.GetCustomFormatId()}")
.DeleteAsync(); .PutJsonAsync(cf.Json)
} .ReceiveJson<JObject>();
}
private IFlurlRequest BuildRequest() => _serverInfo.BuildRequest(); public async Task DeleteCustomFormat(int customFormatId)
{
await BuildRequest()
.AppendPathSegment($"customformat/{customFormatId}")
.DeleteAsync();
} }
private IFlurlRequest BuildRequest() => _serverInfo.BuildRequest();
} }

@ -3,13 +3,12 @@ using System.Threading.Tasks;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using TrashLib.Radarr.CustomFormat.Models; using TrashLib.Radarr.CustomFormat.Models;
namespace TrashLib.Radarr.CustomFormat.Api namespace TrashLib.Radarr.CustomFormat.Api;
public interface ICustomFormatService
{ {
public interface ICustomFormatService Task<List<JObject>> GetCustomFormats();
{ Task CreateCustomFormat(ProcessedCustomFormatData cf);
Task<List<JObject>> GetCustomFormats(); Task UpdateCustomFormat(ProcessedCustomFormatData cf);
Task CreateCustomFormat(ProcessedCustomFormatData cf); Task DeleteCustomFormat(int customFormatId);
Task UpdateCustomFormat(ProcessedCustomFormatData cf);
Task DeleteCustomFormat(int customFormatId);
}
} }

@ -2,11 +2,10 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace TrashLib.Radarr.CustomFormat.Api namespace TrashLib.Radarr.CustomFormat.Api;
public interface IQualityProfileService
{ {
public interface IQualityProfileService Task<List<JObject>> GetQualityProfiles();
{ Task<JObject> UpdateQualityProfile(JObject profileJson, int id);
Task<List<JObject>> GetQualityProfiles();
Task<JObject> UpdateQualityProfile(JObject profileJson, int id);
}
} }

@ -4,32 +4,31 @@ using Flurl.Http;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using TrashLib.Config; using TrashLib.Config;
namespace TrashLib.Radarr.CustomFormat.Api namespace TrashLib.Radarr.CustomFormat.Api;
{
internal class QualityProfileService : IQualityProfileService
{
private readonly IServerInfo _serverInfo;
public QualityProfileService(IServerInfo serverInfo) internal class QualityProfileService : IQualityProfileService
{ {
_serverInfo = serverInfo; private readonly IServerInfo _serverInfo;
}
public async Task<List<JObject>> GetQualityProfiles() public QualityProfileService(IServerInfo serverInfo)
{ {
return await BuildRequest() _serverInfo = serverInfo;
.AppendPathSegment("qualityprofile") }
.GetJsonAsync<List<JObject>>();
}
public async Task<JObject> UpdateQualityProfile(JObject profileJson, int id) public async Task<List<JObject>> GetQualityProfiles()
{ {
return await BuildRequest() return await BuildRequest()
.AppendPathSegment($"qualityprofile/{id}") .AppendPathSegment("qualityprofile")
.PutJsonAsync(profileJson) .GetJsonAsync<List<JObject>>();
.ReceiveJson<JObject>(); }
}
private IFlurlRequest BuildRequest() => _serverInfo.BuildRequest(); public async Task<JObject> UpdateQualityProfile(JObject profileJson, int id)
{
return await BuildRequest()
.AppendPathSegment($"qualityprofile/{id}")
.PutJsonAsync(profileJson)
.ReceiveJson<JObject>();
} }
private IFlurlRequest BuildRequest() => _serverInfo.BuildRequest();
} }

@ -1,10 +1,9 @@
namespace TrashLib.Radarr.CustomFormat namespace TrashLib.Radarr.CustomFormat;
public enum ApiOperationType
{ {
public enum ApiOperationType Create,
{ Update,
Create, NoChange,
Update, Delete
NoChange,
Delete
}
} }

@ -6,63 +6,62 @@ using TrashLib.Cache;
using TrashLib.Radarr.CustomFormat.Models; using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache; using TrashLib.Radarr.CustomFormat.Models.Cache;
namespace TrashLib.Radarr.CustomFormat namespace TrashLib.Radarr.CustomFormat;
internal class CachePersister : ICachePersister
{ {
internal class CachePersister : ICachePersister private readonly IServiceCache _cache;
{
private readonly IServiceCache _cache;
public CachePersister(ILogger log, IServiceCache cache) public CachePersister(ILogger log, IServiceCache cache)
{ {
Log = log; Log = log;
_cache = cache; _cache = cache;
} }
private ILogger Log { get; } private ILogger Log { get; }
public CustomFormatCache? CfCache { get; private set; } public CustomFormatCache? CfCache { get; private set; }
public void Load() public void Load()
{
CfCache = _cache.Load<CustomFormatCache>();
// ReSharper disable once ConvertIfStatementToConditionalTernaryExpression
if (CfCache != null)
{ {
CfCache = _cache.Load<CustomFormatCache>(); Log.Debug("Loaded Cache");
// ReSharper disable once ConvertIfStatementToConditionalTernaryExpression
if (CfCache != null)
{
Log.Debug("Loaded Cache");
// If the version is higher OR lower, we invalidate the cache. It means there's an // If the version is higher OR lower, we invalidate the cache. It means there's an
// incompatibility that we do not support. // incompatibility that we do not support.
if (CfCache.Version != CustomFormatCache.LatestVersion) if (CfCache.Version != CustomFormatCache.LatestVersion)
{
Log.Information("Cache version mismatch ({OldVersion} vs {LatestVersion}); ignoring cache data",
CfCache.Version, CustomFormatCache.LatestVersion);
CfCache = null;
}
}
else
{ {
Log.Debug("Custom format cache does not exist; proceeding without it"); Log.Information("Cache version mismatch ({OldVersion} vs {LatestVersion}); ignoring cache data",
CfCache.Version, CustomFormatCache.LatestVersion);
CfCache = null;
} }
} }
else
public void Save()
{ {
if (CfCache == null) Log.Debug("Custom format cache does not exist; proceeding without it");
{
Log.Debug("Not saving cache because it is null");
return;
}
Log.Debug("Saving Cache");
_cache.Save(CfCache);
} }
}
public void Update(IEnumerable<ProcessedCustomFormatData> customFormats) public void Save()
{
if (CfCache == null)
{ {
Log.Debug("Updating cache"); Log.Debug("Not saving cache because it is null");
CfCache = new CustomFormatCache(); return;
CfCache!.TrashIdMappings.AddRange(customFormats
.Where(cf => cf.CacheEntry != null)
.Select(cf => cf.CacheEntry!));
} }
Log.Debug("Saving Cache");
_cache.Save(CfCache);
}
public void Update(IEnumerable<ProcessedCustomFormatData> customFormats)
{
Log.Debug("Updating cache");
CfCache = new CustomFormatCache();
CfCache!.TrashIdMappings.AddRange(customFormats
.Where(cf => cf.CacheEntry != null)
.Select(cf => cf.CacheEntry!));
} }
} }

@ -7,266 +7,265 @@ using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Processors; using TrashLib.Radarr.CustomFormat.Processors;
using TrashLib.Radarr.CustomFormat.Processors.PersistenceSteps; using TrashLib.Radarr.CustomFormat.Processors.PersistenceSteps;
namespace TrashLib.Radarr.CustomFormat namespace TrashLib.Radarr.CustomFormat;
internal class CustomFormatUpdater : ICustomFormatUpdater
{ {
internal class CustomFormatUpdater : ICustomFormatUpdater private readonly ICachePersister _cache;
private readonly IGuideProcessor _guideProcessor;
private readonly IPersistenceProcessor _persistenceProcessor;
public CustomFormatUpdater(
ILogger log,
ICachePersister cache,
IGuideProcessor guideProcessor,
IPersistenceProcessor persistenceProcessor)
{ {
private readonly ICachePersister _cache; Log = log;
private readonly IGuideProcessor _guideProcessor; _cache = cache;
private readonly IPersistenceProcessor _persistenceProcessor; _guideProcessor = guideProcessor;
_persistenceProcessor = persistenceProcessor;
public CustomFormatUpdater( }
ILogger log,
ICachePersister cache,
IGuideProcessor guideProcessor,
IPersistenceProcessor persistenceProcessor)
{
Log = log;
_cache = cache;
_guideProcessor = guideProcessor;
_persistenceProcessor = persistenceProcessor;
}
private ILogger Log { get; }
public async Task Process(bool isPreview, RadarrConfiguration config) private ILogger Log { get; }
{
_cache.Load();
await _guideProcessor.BuildGuideDataAsync(config.CustomFormats.AsReadOnly(), _cache.CfCache); public async Task Process(bool isPreview, RadarrConfiguration config)
{
_cache.Load();
if (!ValidateGuideDataAndCheckShouldProceed(config)) await _guideProcessor.BuildGuideDataAsync(config.CustomFormats.AsReadOnly(), _cache.CfCache);
{
return;
}
if (isPreview) if (!ValidateGuideDataAndCheckShouldProceed(config))
{ {
PreviewCustomFormats(); return;
} }
else
{
await _persistenceProcessor.PersistCustomFormats(_guideProcessor.ProcessedCustomFormats,
_guideProcessor.DeletedCustomFormatsInCache, _guideProcessor.ProfileScores);
PrintApiStatistics(_persistenceProcessor.Transactions); if (isPreview)
PrintQualityProfileUpdates(); {
PreviewCustomFormats();
}
else
{
await _persistenceProcessor.PersistCustomFormats(_guideProcessor.ProcessedCustomFormats,
_guideProcessor.DeletedCustomFormatsInCache, _guideProcessor.ProfileScores);
// Cache all the custom formats (using ID from API response). PrintApiStatistics(_persistenceProcessor.Transactions);
_cache.Update(_guideProcessor.ProcessedCustomFormats); PrintQualityProfileUpdates();
_cache.Save();
}
_persistenceProcessor.Reset(); // Cache all the custom formats (using ID from API response).
_guideProcessor.Reset(); _cache.Update(_guideProcessor.ProcessedCustomFormats);
_cache.Save();
} }
private void PrintQualityProfileUpdates() _persistenceProcessor.Reset();
_guideProcessor.Reset();
}
private void PrintQualityProfileUpdates()
{
if (_persistenceProcessor.UpdatedScores.Count > 0)
{ {
if (_persistenceProcessor.UpdatedScores.Count > 0) foreach (var (profileName, scores) in _persistenceProcessor.UpdatedScores)
{ {
foreach (var (profileName, scores) in _persistenceProcessor.UpdatedScores) Log.Debug("> Scores updated for quality profile: {ProfileName}", profileName);
{
Log.Debug("> Scores updated for quality profile: {ProfileName}", profileName);
foreach (var (customFormatName, score, reason) in scores) foreach (var (customFormatName, score, reason) in scores)
{ {
Log.Debug(" - {Format}: {Score} ({Reason})", customFormatName, score, reason); Log.Debug(" - {Format}: {Score} ({Reason})", customFormatName, score, reason);
}
} }
Log.Information("Updated {ProfileCount} profiles and a total of {ScoreCount} scores",
_persistenceProcessor.UpdatedScores.Keys.Count,
_persistenceProcessor.UpdatedScores.Sum(s => s.Value.Count));
}
else
{
Log.Information("All quality profile scores are already up to date!");
} }
if (_persistenceProcessor.InvalidProfileNames.Count > 0) Log.Information("Updated {ProfileCount} profiles and a total of {ScoreCount} scores",
{ _persistenceProcessor.UpdatedScores.Keys.Count,
Log.Warning("The following quality profile names are not valid and should either be " + _persistenceProcessor.UpdatedScores.Sum(s => s.Value.Count));
"removed or renamed in your YAML config"); }
Log.Warning("{QualityProfileNames}", _persistenceProcessor.InvalidProfileNames); else
} {
Log.Information("All quality profile scores are already up to date!");
} }
private void PrintApiStatistics(CustomFormatTransactionData transactions) if (_persistenceProcessor.InvalidProfileNames.Count > 0)
{ {
var created = transactions.NewCustomFormats; Log.Warning("The following quality profile names are not valid and should either be " +
if (created.Count > 0) "removed or renamed in your YAML config");
{ Log.Warning("{QualityProfileNames}", _persistenceProcessor.InvalidProfileNames);
Log.Information("Created {Count} New Custom Formats: {CustomFormats}", created.Count, }
created.Select(r => r.Name)); }
}
var updated = transactions.UpdatedCustomFormats; private void PrintApiStatistics(CustomFormatTransactionData transactions)
if (updated.Count > 0) {
{ var created = transactions.NewCustomFormats;
Log.Information("Updated {Count} Existing Custom Formats: {CustomFormats}", updated.Count, if (created.Count > 0)
updated.Select(r => r.Name)); {
} Log.Information("Created {Count} New Custom Formats: {CustomFormats}", created.Count,
created.Select(r => r.Name));
}
var skipped = transactions.UnchangedCustomFormats; var updated = transactions.UpdatedCustomFormats;
if (skipped.Count > 0) if (updated.Count > 0)
{ {
Log.Debug("Skipped {Count} Custom Formats that did not change: {CustomFormats}", skipped.Count, Log.Information("Updated {Count} Existing Custom Formats: {CustomFormats}", updated.Count,
skipped.Select(r => r.Name)); updated.Select(r => r.Name));
} }
var deleted = transactions.DeletedCustomFormatIds; var skipped = transactions.UnchangedCustomFormats;
if (deleted.Count > 0) if (skipped.Count > 0)
{ {
Log.Information("Deleted {Count} Custom Formats: {CustomFormats}", deleted.Count, Log.Debug("Skipped {Count} Custom Formats that did not change: {CustomFormats}", skipped.Count,
deleted.Select(r => r.CustomFormatName)); skipped.Select(r => r.Name));
} }
var totalCount = created.Count + updated.Count; var deleted = transactions.DeletedCustomFormatIds;
if (totalCount > 0) if (deleted.Count > 0)
{ {
Log.Information("Total of {Count} custom formats synced to Radarr", totalCount); Log.Information("Deleted {Count} Custom Formats: {CustomFormats}", deleted.Count,
} deleted.Select(r => r.CustomFormatName));
else
{
Log.Information("All custom formats are already up to date!");
}
} }
private bool ValidateGuideDataAndCheckShouldProceed(RadarrConfiguration config) var totalCount = created.Count + updated.Count;
if (totalCount > 0)
{ {
Console.WriteLine(""); Log.Information("Total of {Count} custom formats synced to Radarr", totalCount);
}
else
{
Log.Information("All custom formats are already up to date!");
}
}
if (_guideProcessor.DuplicatedCustomFormats.Count > 0) private bool ValidateGuideDataAndCheckShouldProceed(RadarrConfiguration config)
{ {
Log.Warning("One or more of the custom formats you want are duplicated in the guide. These custom " + Console.WriteLine("");
"formats WILL BE SKIPPED. Trash Updater is not able to choose which one you actually " +
"wanted. To resolve this ambiguity, use the `trash_ids` property in your YML " +
"configuration to refer to the custom format using its Trash ID instead of its name");
foreach (var (cfName, dupes) in _guideProcessor.DuplicatedCustomFormats) if (_guideProcessor.DuplicatedCustomFormats.Count > 0)
{
Log.Warning("One or more of the custom formats you want are duplicated in the guide. These custom " +
"formats WILL BE SKIPPED. Trash Updater is not able to choose which one you actually " +
"wanted. To resolve this ambiguity, use the `trash_ids` property in your YML " +
"configuration to refer to the custom format using its Trash ID instead of its name");
foreach (var (cfName, dupes) in _guideProcessor.DuplicatedCustomFormats)
{
Log.Warning("{CfName} is duplicated {DupeTimes} with the following Trash IDs:", cfName,
dupes.Count);
foreach (var cf in dupes)
{ {
Log.Warning("{CfName} is duplicated {DupeTimes} with the following Trash IDs:", cfName, Log.Warning(" - {TrashId}", cf.TrashId);
dupes.Count);
foreach (var cf in dupes)
{
Log.Warning(" - {TrashId}", cf.TrashId);
}
} }
Console.WriteLine("");
} }
if (_guideProcessor.CustomFormatsNotInGuide.Count > 0) Console.WriteLine("");
{ }
Log.Warning("The Custom Formats below do not exist in the guide and will " +
"be skipped. Names must match the 'name' field in the actual JSON, not the header in " +
"the guide! Either fix the names or remove them from your YAML config to resolve this " +
"warning");
Log.Warning("{CfList}", _guideProcessor.CustomFormatsNotInGuide);
Console.WriteLine("");
}
var cfsWithoutQualityProfiles = _guideProcessor.ConfigData if (_guideProcessor.CustomFormatsNotInGuide.Count > 0)
.Where(d => d.QualityProfiles.Count == 0) {
.SelectMany(d => d.CustomFormats.Select(cf => cf.Name)) Log.Warning("The Custom Formats below do not exist in the guide and will " +
.ToList(); "be skipped. Names must match the 'name' field in the actual JSON, not the header in " +
"the guide! Either fix the names or remove them from your YAML config to resolve this " +
"warning");
Log.Warning("{CfList}", _guideProcessor.CustomFormatsNotInGuide);
if (cfsWithoutQualityProfiles.Count > 0) Console.WriteLine("");
{ }
Log.Debug("These custom formats will be uploaded but are not associated to a quality profile in the " +
"config file: {UnassociatedCfs}", cfsWithoutQualityProfiles);
Console.WriteLine(""); var cfsWithoutQualityProfiles = _guideProcessor.ConfigData
} .Where(d => d.QualityProfiles.Count == 0)
.SelectMany(d => d.CustomFormats.Select(cf => cf.Name))
.ToList();
// No CFs are defined in this item, or they are all invalid. Skip this whole instance. if (cfsWithoutQualityProfiles.Count > 0)
if (_guideProcessor.ConfigData.Count == 0) {
{ Log.Debug("These custom formats will be uploaded but are not associated to a quality profile in the " +
Log.Error("Guide processing yielded no custom formats for configured instance host {BaseUrl}", "config file: {UnassociatedCfs}", cfsWithoutQualityProfiles);
config.BaseUrl);
return false;
}
if (_guideProcessor.CustomFormatsWithoutScore.Count > 0) Console.WriteLine("");
{ }
Log.Warning("The below custom formats have no score in the guide or YAML " +
"config and will be skipped (remove them from your config or specify a " +
"score to fix this warning)");
foreach (var tuple in _guideProcessor.CustomFormatsWithoutScore)
{
Log.Warning("{CfList}", tuple);
}
Console.WriteLine(""); // No CFs are defined in this item, or they are all invalid. Skip this whole instance.
} if (_guideProcessor.ConfigData.Count == 0)
{
Log.Error("Guide processing yielded no custom formats for configured instance host {BaseUrl}",
config.BaseUrl);
return false;
}
if (_guideProcessor.CustomFormatsWithOutdatedNames.Count > 0) if (_guideProcessor.CustomFormatsWithoutScore.Count > 0)
{
Log.Warning("The below custom formats have no score in the guide or YAML " +
"config and will be skipped (remove them from your config or specify a " +
"score to fix this warning)");
foreach (var tuple in _guideProcessor.CustomFormatsWithoutScore)
{ {
Log.Warning("One or more custom format names in your YAML config have been renamed in the guide and " + Log.Warning("{CfList}", tuple);
"are outdated. Each outdated name will be listed below. These custom formats will refuse " +
"to sync if your cache is deleted. To fix this warning, rename each one to its new name");
foreach (var (oldName, newName) in _guideProcessor.CustomFormatsWithOutdatedNames)
{
Log.Warning(" - '{OldName}' -> '{NewName}'", oldName, newName);
}
Console.WriteLine("");
} }
return true; Console.WriteLine("");
} }
private void PreviewCustomFormats() if (_guideProcessor.CustomFormatsWithOutdatedNames.Count > 0)
{ {
Console.WriteLine(""); Log.Warning("One or more custom format names in your YAML config have been renamed in the guide and " +
Console.WriteLine("========================================================="); "are outdated. Each outdated name will be listed below. These custom formats will refuse " +
Console.WriteLine(" >>> Custom Formats From Guide <<< "); "to sync if your cache is deleted. To fix this warning, rename each one to its new name");
Console.WriteLine("=========================================================");
Console.WriteLine("");
const string format = "{0,-30} {1,-35}";
Console.WriteLine(format, "Custom Format", "Trash ID");
Console.WriteLine(string.Concat(Enumerable.Repeat('-', 1 + 30 + 35)));
foreach (var cf in _guideProcessor.ProcessedCustomFormats) foreach (var (oldName, newName) in _guideProcessor.CustomFormatsWithOutdatedNames)
{ {
Console.WriteLine(format, cf.Name, cf.TrashId); Log.Warning(" - '{OldName}' -> '{NewName}'", oldName, newName);
} }
Console.WriteLine(""); Console.WriteLine("");
Console.WriteLine("========================================================="); }
Console.WriteLine(" >>> Quality Profile Assignments & Scores <<< ");
Console.WriteLine("=========================================================");
Console.WriteLine("");
const string profileFormat = "{0,-18} {1,-20} {2,-8}"; return true;
Console.WriteLine(profileFormat, "Profile", "Custom Format", "Score"); }
Console.WriteLine(string.Concat(Enumerable.Repeat('-', 2 + 18 + 20 + 8)));
foreach (var (profileName, scoreMap) in _guideProcessor.ProfileScores) private void PreviewCustomFormats()
{ {
Console.WriteLine(profileFormat, profileName, "", ""); Console.WriteLine("");
Console.WriteLine("=========================================================");
Console.WriteLine(" >>> Custom Formats From Guide <<< ");
Console.WriteLine("=========================================================");
Console.WriteLine("");
foreach (var (customFormat, score) in scoreMap.Mapping) const string format = "{0,-30} {1,-35}";
{ Console.WriteLine(format, "Custom Format", "Trash ID");
var matchingCf = _guideProcessor.ProcessedCustomFormats Console.WriteLine(string.Concat(Enumerable.Repeat('-', 1 + 30 + 35)));
.FirstOrDefault(cf => cf.TrashId.EqualsIgnoreCase(customFormat.TrashId));
foreach (var cf in _guideProcessor.ProcessedCustomFormats)
{
Console.WriteLine(format, cf.Name, cf.TrashId);
}
Console.WriteLine("");
Console.WriteLine("=========================================================");
Console.WriteLine(" >>> Quality Profile Assignments & Scores <<< ");
Console.WriteLine("=========================================================");
Console.WriteLine("");
const string profileFormat = "{0,-18} {1,-20} {2,-8}";
Console.WriteLine(profileFormat, "Profile", "Custom Format", "Score");
Console.WriteLine(string.Concat(Enumerable.Repeat('-', 2 + 18 + 20 + 8)));
foreach (var (profileName, scoreMap) in _guideProcessor.ProfileScores)
{
Console.WriteLine(profileFormat, profileName, "", "");
if (matchingCf == null) foreach (var (customFormat, score) in scoreMap.Mapping)
{ {
Log.Warning("Quality Profile refers to CF not found in guide: {TrashId}", var matchingCf = _guideProcessor.ProcessedCustomFormats
customFormat.TrashId); .FirstOrDefault(cf => cf.TrashId.EqualsIgnoreCase(customFormat.TrashId));
continue;
}
Console.WriteLine(profileFormat, "", matchingCf.Name, score); if (matchingCf == null)
{
Log.Warning("Quality Profile refers to CF not found in guide: {TrashId}",
customFormat.TrashId);
continue;
} }
}
Console.WriteLine(""); Console.WriteLine(profileFormat, "", matchingCf.Name, score);
}
} }
Console.WriteLine("");
} }
} }

@ -1,10 +1,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace TrashLib.Radarr.CustomFormat.Guide namespace TrashLib.Radarr.CustomFormat.Guide;
public interface IRadarrGuideService
{ {
public interface IRadarrGuideService Task<IEnumerable<string>> GetCustomFormatJsonAsync();
{
Task<IEnumerable<string>> GetCustomFormatJsonAsync();
}
} }

@ -2,13 +2,12 @@ using System.Collections.Generic;
using TrashLib.Radarr.CustomFormat.Models; using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache; using TrashLib.Radarr.CustomFormat.Models.Cache;
namespace TrashLib.Radarr.CustomFormat namespace TrashLib.Radarr.CustomFormat;
public interface ICachePersister
{ {
public interface ICachePersister CustomFormatCache? CfCache { get; }
{ void Load();
CustomFormatCache? CfCache { get; } void Save();
void Load(); void Update(IEnumerable<ProcessedCustomFormatData> customFormats);
void Save();
void Update(IEnumerable<ProcessedCustomFormatData> customFormats);
}
} }

@ -1,10 +1,9 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using TrashLib.Radarr.Config; using TrashLib.Radarr.Config;
namespace TrashLib.Radarr.CustomFormat namespace TrashLib.Radarr.CustomFormat;
public interface ICustomFormatUpdater
{ {
public interface ICustomFormatUpdater Task Process(bool isPreview, RadarrConfiguration config);
{
Task Process(bool isPreview, RadarrConfiguration config);
}
} }

@ -1,28 +1,27 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using TrashLib.Cache; using TrashLib.Cache;
namespace TrashLib.Radarr.CustomFormat.Models.Cache namespace TrashLib.Radarr.CustomFormat.Models.Cache;
[CacheObjectName("custom-format-cache")]
public class CustomFormatCache
{ {
[CacheObjectName("custom-format-cache")] public const int LatestVersion = 1;
public class CustomFormatCache
{
public const int LatestVersion = 1;
public int Version { get; init; } = LatestVersion; public int Version { get; init; } = LatestVersion;
public Collection<TrashIdMapping> TrashIdMappings { get; init; } = new(); public Collection<TrashIdMapping> TrashIdMappings { get; init; } = new();
} }
public class TrashIdMapping public class TrashIdMapping
{
public TrashIdMapping(string trashId, string customFormatName, int customFormatId = default)
{ {
public TrashIdMapping(string trashId, string customFormatName, int customFormatId = default) CustomFormatName = customFormatName;
{ TrashId = trashId;
CustomFormatName = customFormatName; CustomFormatId = customFormatId;
TrashId = trashId;
CustomFormatId = customFormatId;
}
public string CustomFormatName { get; set; }
public string TrashId { get; }
public int CustomFormatId { get; set; }
} }
public string CustomFormatName { get; set; }
public string TrashId { get; }
public int CustomFormatId { get; set; }
} }

@ -1,14 +1,13 @@
using System.Collections.Generic; using System.Collections.Generic;
using TrashLib.Radarr.Config; using TrashLib.Radarr.Config;
namespace TrashLib.Radarr.CustomFormat.Models namespace TrashLib.Radarr.CustomFormat.Models;
public class ProcessedConfigData
{ {
public class ProcessedConfigData public ICollection<ProcessedCustomFormatData> CustomFormats { get; init; }
{ = new List<ProcessedCustomFormatData>();
public ICollection<ProcessedCustomFormatData> CustomFormats { get; init; }
= new List<ProcessedCustomFormatData>();
public ICollection<QualityProfileConfig> QualityProfiles { get; init; } public ICollection<QualityProfileConfig> QualityProfiles { get; init; }
= new List<QualityProfileConfig>(); = new List<QualityProfileConfig>();
}
} }

@ -3,34 +3,33 @@ using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using TrashLib.Radarr.CustomFormat.Models.Cache; using TrashLib.Radarr.CustomFormat.Models.Cache;
namespace TrashLib.Radarr.CustomFormat.Models namespace TrashLib.Radarr.CustomFormat.Models;
public class ProcessedCustomFormatData
{ {
public class ProcessedCustomFormatData public ProcessedCustomFormatData(string name, string trashId, JObject json)
{ {
public ProcessedCustomFormatData(string name, string trashId, JObject json) Name = name;
{ TrashId = trashId;
Name = name; Json = json;
TrashId = trashId; }
Json = json;
}
public string Name { get; }
public string TrashId { get; }
public int? Score { get; init; }
public JObject Json { get; set; }
public TrashIdMapping? CacheEntry { get; set; }
public string CacheAwareName => CacheEntry?.CustomFormatName ?? Name; public string Name { get; }
public string TrashId { get; }
public int? Score { get; init; }
public JObject Json { get; set; }
public TrashIdMapping? CacheEntry { get; set; }
public void SetCache(int customFormatId) public string CacheAwareName => CacheEntry?.CustomFormatName ?? Name;
{
CacheEntry ??= new TrashIdMapping(TrashId, Name);
CacheEntry.CustomFormatId = customFormatId;
}
[SuppressMessage("Microsoft.Design", "CA1024", Justification = "Method throws an exception")] public void SetCache(int customFormatId)
public int GetCustomFormatId() {
=> CacheEntry?.CustomFormatId ?? CacheEntry ??= new TrashIdMapping(TrashId, Name);
throw new InvalidOperationException("CacheEntry must exist to obtain custom format ID"); CacheEntry.CustomFormatId = customFormatId;
} }
[SuppressMessage("Microsoft.Design", "CA1024", Justification = "Method throws an exception")]
public int GetCustomFormatId()
=> CacheEntry?.CustomFormatId ??
throw new InvalidOperationException("CacheEntry must exist to obtain custom format ID");
} }

@ -1,17 +1,16 @@
using System.Collections.Generic; using System.Collections.Generic;
namespace TrashLib.Radarr.CustomFormat.Models namespace TrashLib.Radarr.CustomFormat.Models;
{
public record FormatMappingEntry(ProcessedCustomFormatData CustomFormat, int Score);
public class QualityProfileCustomFormatScoreMapping public record FormatMappingEntry(ProcessedCustomFormatData CustomFormat, int Score);
{
public QualityProfileCustomFormatScoreMapping(bool resetUnmatchedScores)
{
ResetUnmatchedScores = resetUnmatchedScores;
}
public bool ResetUnmatchedScores { get; } public class QualityProfileCustomFormatScoreMapping
public ICollection<FormatMappingEntry> Mapping { get; init; } = new List<FormatMappingEntry>(); {
public QualityProfileCustomFormatScoreMapping(bool resetUnmatchedScores)
{
ResetUnmatchedScores = resetUnmatchedScores;
} }
public bool ResetUnmatchedScores { get; }
public ICollection<FormatMappingEntry> Mapping { get; init; } = new List<FormatMappingEntry>();
} }

@ -1,13 +1,12 @@
namespace TrashLib.Radarr.CustomFormat.Models namespace TrashLib.Radarr.CustomFormat.Models;
{
public enum FormatScoreUpdateReason
{
Updated,
Reset
}
public record UpdatedFormatScore( public enum FormatScoreUpdateReason
string CustomFormatName, {
int Score, Updated,
FormatScoreUpdateReason Reason); Reset
} }
public record UpdatedFormatScore(
string CustomFormatName,
int Score,
FormatScoreUpdateReason Reason);

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save