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

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

@ -2,37 +2,36 @@ using System;
using System.Collections;
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
public static IReadOnlyCollection<T> AsReadOnly<T>(this ICollection<T> source)
if (source is null)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}
return source as IReadOnlyCollection<T> ?? new ReadOnlyCollectionAdapter<T>(source);
throw new ArgumentNullException(nameof(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();
}
return source as IReadOnlyCollection<T> ?? new ReadOnlyCollectionAdapter<T>(source);
}
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;
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)
where TValue : new()
if (!dict.TryGetValue(key, out var val))
{
if (!dict.TryGetValue(key, out var val))
{
val = new TValue();
dict.Add(key, val);
}
return val;
val = new TValue();
dict.Add(key, val);
}
public static TValue? GetOrDefault<TKey, TValue>(this IDictionary<TKey, TValue> dict, TKey key)
{
return dict.TryGetValue(key, out var val) ? val : default;
}
return val;
}
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.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
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())
{
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>,
IPropertyValidator<T, TProperty?>, IAsyncPropertyValidator<T, TProperty?>
private sealed class NullableChildValidatorAdaptor<T, TProperty> : ChildValidatorAdaptor<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,
CancellationToken cancellation)
{
return base.IsValidAsync(context, value!, cancellation);
}
public override Task<bool> IsValidAsync(ValidationContext<T> context, TProperty? value,
CancellationToken cancellation)
{
return base.IsValidAsync(context, value!, cancellation);
}
public override bool IsValid(ValidationContext<T> context, TProperty? value)
{
return base.IsValid(context, value!);
}
public override bool IsValid(ValidationContext<T> context, TProperty? value)
{
return base.IsValid(context, value!);
}
}
}

@ -1,18 +1,17 @@
using System.Diagnostics.CodeAnalysis;
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",
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)
{
match = re.Match(strToCheck);
return match.Success;
}
match = re.Match(strToCheck);
return match.Success;
}
}

@ -3,46 +3,45 @@ using System.Reactive.Disposables;
using System.Reactive.Linq;
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}: Observable obtained on Thread: {ThreadId}",
log.Debug("{OpName}: Subscribed to on Thread: {ThreadId}",
opName,
Environment.CurrentManagedThreadId);
return Observable.Create<T>(obs =>
try
{
log.Debug("{OpName}: Subscribed to on Thread: {ThreadId}",
opName,
Environment.CurrentManagedThreadId);
try
{
var subscription = source
.Do(
x => log.Debug("{OpName}: OnNext({Result}) on Thread: {ThreadId}", opName, x,
Environment.CurrentManagedThreadId),
ex => log.Debug("{OpName}: OnError({Result}) on Thread: {ThreadId}", opName, ex.Message,
Environment.CurrentManagedThreadId),
() => log.Debug("{OpName}: OnCompleted() on Thread: {ThreadId}", opName,
Environment.CurrentManagedThreadId))
.Subscribe(obs);
return new CompositeDisposable(
subscription,
Disposable.Create(() => log.Debug(
"{OpName}: Cleaned up on Thread: {ThreadId}",
opName,
Environment.CurrentManagedThreadId)));
}
finally
{
log.Debug("{OpName}: Subscription completed", opName);
}
});
}
var subscription = source
.Do(
x => log.Debug("{OpName}: OnNext({Result}) on Thread: {ThreadId}", opName, x,
Environment.CurrentManagedThreadId),
ex => log.Debug("{OpName}: OnError({Result}) on Thread: {ThreadId}", opName, ex.Message,
Environment.CurrentManagedThreadId),
() => log.Debug("{OpName}: OnCompleted() on Thread: {ThreadId}", opName,
Environment.CurrentManagedThreadId))
.Subscribe(obs);
return new CompositeDisposable(
subscription,
Disposable.Create(() => log.Debug(
"{OpName}: Cleaned up on Thread: {ThreadId}",
opName,
Environment.CurrentManagedThreadId)));
}
finally
{
log.Debug("{OpName}: Subscription completed", opName);
}
});
}
}

@ -1,33 +1,32 @@
using System;
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)
{
return value.Equals(matchThis, StringComparison.OrdinalIgnoreCase);
}
public static bool EqualsIgnoreCase(this string value, string? matchThis)
{
return value.Equals(matchThis, StringComparison.OrdinalIgnoreCase);
}
public static float ToFloat(this string value)
{
return float.Parse(value, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat);
}
public static float ToFloat(this string value)
{
return float.Parse(value, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat);
}
public static decimal ToDecimal(this string value)
{
return decimal.Parse(value, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat);
}
public static decimal ToDecimal(this string value)
{
return decimal.Parse(value, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat);
}
public static string FormatWith(this string value, params object[] args)
{
return string.Format(value, args);
}
public static string FormatWith(this string value, params object[] args)
{
return string.Format(value, args);
}
}

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

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

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

@ -3,43 +3,42 @@ using System.ComponentModel.DataAnnotations;
using YamlDotNet.Core;
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,
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);
var context = new ValidationContext(value, null, null);
try
{
Validator.ValidateObject(value, context, true);
}
catch (ValidationException e)
try
{
Validator.ValidateObject(value, context, true);
}
catch (ValidationException e)
{
if (reader.Current == null)
{
if (reader.Current == null)
{
throw;
}
throw new YamlException(reader.Current.Start, reader.Current.End, e.Message);
throw;
}
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.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)
where T : class
{
var extractor = deserializer.Deserialize<RootExtractor<T>>(data);
return extractor.RootObject;
}
var extractor = deserializer.Deserialize<RootExtractor<T>>(data);
return extractor.RootObject;
}
public static DeserializerBuilder WithRequiredPropertyValidation(this DeserializerBuilder builder)
{
return builder
.WithNodeDeserializer(inner => new ValidatingDeserializer(inner),
s => s.InsteadOf<ObjectNodeDeserializer>());
}
public static DeserializerBuilder WithRequiredPropertyValidation(this DeserializerBuilder builder)
{
return builder
.WithNodeDeserializer(inner => new ValidatingDeserializer(inner),
s => s.InsteadOf<ObjectNodeDeserializer>());
}
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
private sealed class RootExtractor<T>
where T : class
{
public T? RootObject { get; }
}
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
private sealed class RootExtractor<T>
where T : class
{
public T? RootObject { get; }
}
}

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

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

@ -1,17 +1,16 @@
using FluentAssertions;
using NUnit.Framework;
namespace TestLibrary.Tests
namespace TestLibrary.Tests;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class StringUtilsTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class StringUtilsTest
[Test]
public void TrimmedString_Newlines_AreStripped()
{
[Test]
public void TrimmedString_Newlines_AreStripped()
{
var testStr = "\r\ntest\r\n";
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 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,
IEquivalencyValidator nestedValidator)
var canHandle = comparands.Subject?.GetType().IsAssignableTo(typeof(JToken)) ?? false;
if (!canHandle)
{
var canHandle = comparands.Subject?.GetType().IsAssignableTo(typeof(JToken)) ?? false;
if (!canHandle)
{
return EquivalencyResult.ContinueWithNext;
}
return EquivalencyResult.ContinueWithNext;
}
((JToken) comparands.Subject!).Should().BeEquivalentTo(
(JToken) comparands.Expectation, context.Reason.FormattedMessage, context.Reason.Arguments);
((JToken) comparands.Subject!).Should().BeEquivalentTo(
(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 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)
{
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;
}
failures.ForEach(x => Trace.WriteLine(x));
return false;
}
}
}

@ -1,14 +1,13 @@
using System.IO;
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
namespace Trash.Tests.Command
namespace Trash.Tests.Command;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CreateConfigCommandTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CreateConfigCommandTest
[Test]
public async Task CreateConfig_DefaultPath_FileIsCreated()
{
[Test]
public async Task CreateConfig_DefaultPath_FileIsCreated()
{
var logger = Substitute.For<ILogger>();
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().WriteAllText(Arg.Is<string>(s => s.EndsWith("trash.yml")), Arg.Any<string>());
}
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>());
}
[Test]
public async Task CreateConfig_SpecifyPath_FileIsCreated()
[Test]
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>();
var filesystem = Substitute.For<IFileSystem>();
var cmd = new CreateConfigCommand(logger, filesystem)
{
Path = "some/other/path.yml"
};
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>());
}
Path = "some/other/path.yml"
};
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 Trash.Command.Helpers;
namespace Trash.Tests.Command.Helpers
namespace Trash.Tests.Command.Helpers;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CliTypeActivatorTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CliTypeActivatorTest
private class NonServiceCommandType
{
}
private class StubCommand : IServiceCommand
{
private class NonServiceCommandType
{
}
private class StubCommand : IServiceCommand
{
public bool Preview => false;
public bool Debug => false;
public ICollection<string>? Config => null;
public string CacheStoragePath => "";
}
[Test]
public void Resolve_NonServiceCommandType_NoActiveCommandSet()
{
var builder = new ContainerBuilder();
builder.RegisterType<NonServiceCommandType>();
var container = CompositionRoot.Setup(builder);
var createdType = CliTypeActivator.ResolveType(container, typeof(NonServiceCommandType));
Action act = () => _ = container.Resolve<IActiveServiceCommandProvider>().ActiveCommand;
createdType.Should().BeOfType<NonServiceCommandType>();
act.Should()
.Throw<InvalidOperationException>()
.WithMessage("The active command has not yet been determined");
}
[Test]
public void Resolve_ServiceCommandType_ActiveCommandSet()
{
var builder = new ContainerBuilder();
builder.RegisterType<StubCommand>();
var container = CompositionRoot.Setup(builder);
var createdType = CliTypeActivator.ResolveType(container, typeof(StubCommand));
var activeCommand = container.Resolve<IActiveServiceCommandProvider>().ActiveCommand;
activeCommand.Should().BeSameAs(createdType);
activeCommand.Should().BeOfType<StubCommand>();
}
public bool Preview => false;
public bool Debug => false;
public ICollection<string>? Config => null;
public string CacheStoragePath => "";
}
[Test]
public void Resolve_NonServiceCommandType_NoActiveCommandSet()
{
var builder = new ContainerBuilder();
builder.RegisterType<NonServiceCommandType>();
var container = CompositionRoot.Setup(builder);
var createdType = CliTypeActivator.ResolveType(container, typeof(NonServiceCommandType));
Action act = () => _ = container.Resolve<IActiveServiceCommandProvider>().ActiveCommand;
createdType.Should().BeOfType<NonServiceCommandType>();
act.Should()
.Throw<InvalidOperationException>()
.WithMessage("The active command has not yet been determined");
}
[Test]
public void Resolve_ServiceCommandType_ActiveCommandSet()
{
var builder = new ContainerBuilder();
builder.RegisterType<StubCommand>();
var container = CompositionRoot.Setup(builder);
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 NUnit.Framework;
namespace Trash.Tests
namespace Trash.Tests;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CompositionRootTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CompositionRootTest
private sealed class ConcreteTypeEnumerator : IEnumerable
{
private sealed class ConcreteTypeEnumerator : IEnumerable
{
private readonly IContainer _container;
private readonly IContainer _container;
public ConcreteTypeEnumerator()
{
_container = CompositionRoot.Setup();
}
public IEnumerator GetEnumerator()
{
return _container.ComponentRegistry.Registrations
.SelectMany(x => x.Services)
.OfType<TypedService>()
.GetEnumerator();
}
public ConcreteTypeEnumerator()
{
_container = CompositionRoot.Setup();
}
[TestCaseSource(typeof(ConcreteTypeEnumerator))]
public void Resolve_ICommandConcreteClasses(Service service)
public IEnumerator GetEnumerator()
{
using var container = CompositionRoot.Setup();
container.Invoking(c => c.ResolveService(service))
.Should().NotThrow()
.And.NotBeNull();
return _container.ComponentRegistry.Registrations
.SelectMany(x => x.Services)
.OfType<TypedService>()
.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 YamlDotNet.Serialization.ObjectFactories;
namespace Trash.Tests.Config
namespace Trash.Tests.Config;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigurationLoaderTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigurationLoaderTest
private static TextReader GetResourceData(string file)
{
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",
Justification = "YamlDotNet requires this type to be public so it may access it")]
public class TestConfig : IServiceConfiguration
[SuppressMessage("Microsoft.Design", "CA1034",
Justification = "YamlDotNet requires this type to be public so it may access it")]
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 => "";
public string ApiKey => "";
var str = new StringBuilder("sonarr:");
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]
public void Load_many_iterations_of_config()
{
static StreamReader MockYaml(params object[] args)
{
var str = new StringBuilder("sonarr:");
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());
}
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>();
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
provider.ActiveConfiguration = Arg.Do<SonarrConfiguration>(a => actualActiveConfigs.Add(a));
provider.ActiveConfiguration = Arg.Do<SonarrConfiguration>(a => actualActiveConfigs.Add(a));
#pragma warning restore NS1004
var validator = Substitute.For<IValidator<SonarrConfiguration>>();
var loader =
new ConfigurationLoader<SonarrConfiguration>(provider, fs, new DefaultObjectFactory(), validator);
var validator = Substitute.For<IValidator<SonarrConfiguration>>();
var loader =
new ConfigurationLoader<SonarrConfiguration>(provider, fs, new DefaultObjectFactory(), validator);
var fakeFiles = new List<string>
{
"config1.yml",
"config2.yml"
};
var fakeFiles = new List<string>
{
"config1.yml",
"config2.yml"
};
var expected = new List<SonarrConfiguration>
{
new() {ApiKey = "abc", BaseUrl = "1"},
new() {ApiKey = "abc", BaseUrl = "2"},
new() {ApiKey = "abc", BaseUrl = "3"}
};
var expected = new List<SonarrConfiguration>
{
new() {ApiKey = "abc", BaseUrl = "1"},
new() {ApiKey = "abc", BaseUrl = "2"},
new() {ApiKey = "abc", BaseUrl = "3"}
};
var actual = loader.LoadMany(fakeFiles, "sonarr").ToList();
var actual = loader.LoadMany(fakeFiles, "sonarr").ToList();
actual.Should().BeEquivalentTo(expected);
actualActiveConfigs.Should().BeEquivalentTo(expected, op => op.WithoutStrictOrdering());
}
actual.Should().BeEquivalentTo(expected);
actualActiveConfigs.Should().BeEquivalentTo(expected, op => op.WithoutStrictOrdering());
}
[Test]
public void Parse_using_stream()
{
var validator = Substitute.For<IValidator<SonarrConfiguration>>();
var configLoader = new ConfigurationLoader<SonarrConfiguration>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(),
new DefaultObjectFactory(),
validator);
[Test]
public void Parse_using_stream()
{
var validator = Substitute.For<IValidator<SonarrConfiguration>>();
var configLoader = new ConfigurationLoader<SonarrConfiguration>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(),
new DefaultObjectFactory(),
validator);
var configs = configLoader.LoadFromStream(GetResourceData("Load_UsingStream_CorrectParsing.yml"), "sonarr");
var configs = configLoader.LoadFromStream(GetResourceData("Load_UsingStream_CorrectParsing.yml"), "sonarr");
configs.Should()
.BeEquivalentTo(new List<SonarrConfiguration>
configs.Should()
.BeEquivalentTo(new List<SonarrConfiguration>
{
new()
{
new()
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = "http://localhost:8989",
ReleaseProfiles = new List<ReleaseProfileConfig>
{
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = "http://localhost:8989",
ReleaseProfiles = new List<ReleaseProfileConfig>
new()
{
new()
{
Type = ReleaseProfileType.Anime,
StrictNegativeScores = true,
Tags = new List<string> {"anime"}
},
new()
Type = ReleaseProfileType.Anime,
StrictNegativeScores = true,
Tags = new List<string> {"anime"}
},
new()
{
Type = ReleaseProfileType.Series,
StrictNegativeScores = false,
Tags = new List<string>
{
Type = ReleaseProfileType.Series,
StrictNegativeScores = false,
Tags = new List<string>
{
"tv",
"series"
}
"tv",
"series"
}
}
}
});
}
}
});
}
[Test]
public void Throw_when_validation_fails()
[Test]
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>>();
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")}
});
Errors = {new ValidationFailure("PropertyName", "Test Validation Failure")}
});
var testYml = @"
var testYml = @"
fubar:
- 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]
public void Validation_success_does_not_throw()
{
var validator = Substitute.For<IValidator<TestConfig>>();
var configLoader = new ConfigurationLoader<TestConfig>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(),
new DefaultObjectFactory(),
validator);
var testYml = @"
[Test]
public void Validation_success_does_not_throw()
{
var validator = Substitute.For<IValidator<TestConfig>>();
var configLoader = new ConfigurationLoader<TestConfig>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(),
new DefaultObjectFactory(),
validator);
var testYml = @"
fubar:
- api_key: abc
";
Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "fubar");
act.Should().NotThrow();
}
Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "fubar");
act.Should().NotThrow();
}
}

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

@ -1,17 +1,16 @@
using System;
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 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")]
[UsedImplicitly]
public class CreateConfigCommand : ICommand
private readonly IFileSystem _fileSystem;
public CreateConfigCommand(ILogger logger, IFileSystem fileSystem)
{
private readonly IFileSystem _fileSystem;
Log = logger;
_fileSystem = fileSystem;
}
public CreateConfigCommand(ILogger logger, IFileSystem fileSystem)
{
Log = logger;
_fileSystem = fileSystem;
}
private ILogger Log { get; }
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 =
"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;
public ValueTask ExecuteAsync(IConsole console)
{
var reader = new ResourceDataReader(typeof(Program));
var ymlData = reader.ReadData("trash-config-template.yml");
public ValueTask ExecuteAsync(IConsole console)
if (_fileSystem.File.Exists(Path))
{
var reader = new ResourceDataReader(typeof(Program));
var ymlData = reader.ReadData("trash-config-template.yml");
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;
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;
}
}

@ -1,16 +1,15 @@
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
{
get => _activeCommand ??
throw new InvalidOperationException("The active command has not yet been determined");
set => _activeCommand = value;
}
public IServiceCommand ActiveCommand
{
get => _activeCommand ??
throw new InvalidOperationException("The active command has not yet been determined");
set => _activeCommand = value;
}
}

@ -1,16 +1,15 @@
using TrashLib.Cache;
namespace Trash.Command.Helpers
{
public class CacheStoragePath : ICacheStoragePath
{
private readonly IActiveServiceCommandProvider _serviceCommandProvider;
namespace Trash.Command.Helpers;
public CacheStoragePath(IActiveServiceCommandProvider serviceCommandProvider)
{
_serviceCommandProvider = serviceCommandProvider;
}
public class CacheStoragePath : ICacheStoragePath
{
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 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);
if (instance.GetType().IsAssignableTo<IServiceCommand>())
{
var activeServiceProvider = container.Resolve<IActiveServiceCommandProvider>();
activeServiceProvider.ActiveCommand = (IServiceCommand) instance;
}
return instance;
var activeServiceProvider = container.Resolve<IActiveServiceCommandProvider>();
activeServiceProvider.ActiveCommand = (IServiceCommand) 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;
namespace Trash.Command.Helpers
namespace Trash.Command.Helpers;
public interface IServiceCommand
{
public interface IServiceCommand
{
bool Preview { get; }
bool Debug { get; }
ICollection<string>? Config { get; }
string CacheStoragePath { get; }
}
bool Preview { get; }
bool Debug { get; }
ICollection<string>? Config { get; }
string CacheStoragePath { get; }
}

@ -15,109 +15,108 @@ using Serilog.Events;
using TrashLib.Extensions;
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;
private readonly LoggingLevelSwitch _loggingLevelSwitch;
private readonly ILogJanitor _logJanitor;
protected ServiceCommand(
ILogger log,
LoggingLevelSwitch loggingLevelSwitch,
ILogJanitor logJanitor)
_loggingLevelSwitch = loggingLevelSwitch;
_logJanitor = logJanitor;
_log = log;
}
public async ValueTask ExecuteAsync(IConsole console)
{
SetupLogging();
SetupHttp();
try
{
_loggingLevelSwitch = loggingLevelSwitch;
_logJanitor = logJanitor;
_log = log;
await Process();
}
public async ValueTask ExecuteAsync(IConsole console)
catch (YamlException e)
{
SetupLogging();
SetupHttp();
try
{
await Process();
}
catch (YamlException e)
var inner = e.InnerException;
if (inner == null)
{
var inner = e.InnerException;
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();
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();
}
}
[CommandOption("preview", 'p', Description =
"Only display the processed markdown results without making any API calls.")]
public bool Preview { get; [UsedImplicitly] set; } = false;
[CommandOption("preview", 'p', Description =
"Only display the processed markdown results without making any API calls.")]
public bool Preview { get; [UsedImplicitly] set; } = false;
[CommandOption("debug", 'd', Description =
"Display additional logs useful for development/debug purposes.")]
public bool Debug { get; [UsedImplicitly] set; } = false;
[CommandOption("debug", 'd', Description =
"Display additional logs useful for development/debug purposes.")]
public bool Debug { get; [UsedImplicitly] set; } = false;
[CommandOption("config", 'c', Description =
"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.")]
public ICollection<string> Config { get; [UsedImplicitly] set; } =
new List<string> {AppPaths.DefaultConfigPath};
[CommandOption("config", 'c', Description =
"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.")]
public ICollection<string> Config { get; [UsedImplicitly] set; } =
new List<string> {AppPaths.DefaultConfigPath};
public abstract string CacheStoragePath { get; }
public abstract string CacheStoragePath { get; }
private void CleanupOldLogFiles()
{
_logJanitor.DeleteOldestLogFiles(20);
}
private void CleanupOldLogFiles()
{
_logJanitor.DeleteOldestLogFiles(20);
}
private void SetupLogging()
{
_loggingLevelSwitch.MinimumLevel =
Debug ? LogEventLevel.Debug : LogEventLevel.Information;
}
private void SetupLogging()
{
_loggingLevelSwitch.MinimumLevel =
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
// 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.
NullValueHandling = NullValueHandling.Ignore
};
settings.JsonSerializer = new NewtonsoftJsonSerializer(jsonSettings);
FlurlLogging.SetupLogging(settings, _log);
});
}
// 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
// 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.
NullValueHandling = NullValueHandling.Ignore
};
settings.JsonSerializer = new NewtonsoftJsonSerializer(jsonSettings);
FlurlLogging.SetupLogging(settings, _log);
});
}
public abstract Task Process();
public abstract Task Process();
protected static void ExitDueToFailure()
{
throw new CommandException("Exiting due to previous exception");
}
protected static void ExitDueToFailure()
{
throw new CommandException("Exiting due to previous exception");
}
}

@ -12,57 +12,56 @@ using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat;
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")]
[UsedImplicitly]
public class RadarrCommand : ServiceCommand
{
private readonly IConfigurationLoader<RadarrConfiguration> _configLoader;
private readonly Func<ICustomFormatUpdater> _customFormatUpdaterFactory;
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(
ILogger log,
LoggingLevelSwitch loggingLevelSwitch,
ILogJanitor logJanitor,
IConfigurationLoader<RadarrConfiguration> configLoader,
Func<IRadarrQualityDefinitionUpdater> qualityUpdaterFactory,
Func<ICustomFormatUpdater> customFormatUpdaterFactory)
: base(log, loggingLevelSwitch, logJanitor)
{
_log = log;
_configLoader = configLoader;
_qualityUpdaterFactory = qualityUpdaterFactory;
_customFormatUpdaterFactory = customFormatUpdaterFactory;
}
public RadarrCommand(
ILogger log,
LoggingLevelSwitch loggingLevelSwitch,
ILogJanitor logJanitor,
IConfigurationLoader<RadarrConfiguration> configLoader,
Func<IRadarrQualityDefinitionUpdater> qualityUpdaterFactory,
Func<ICustomFormatUpdater> customFormatUpdaterFactory)
: base(log, loggingLevelSwitch, logJanitor)
{
_log = log;
_configLoader = configLoader;
_qualityUpdaterFactory = qualityUpdaterFactory;
_customFormatUpdaterFactory = customFormatUpdaterFactory;
}
public override string CacheStoragePath { get; } =
Path.Combine(AppPaths.AppDataPath, "cache", "radarr");
public override string CacheStoragePath { get; } =
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)
{
await _customFormatUpdaterFactory().Process(Preview, config);
}
if (config.CustomFormats.Count > 0)
{
await _customFormatUpdaterFactory().Process(Preview, config);
}
}
catch (FlurlHttpException e)
{
_log.Error(e, "HTTP error while communicating with Radarr");
ExitDueToFailure();
}
}
catch (FlurlHttpException e)
{
_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.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")]
[UsedImplicitly]
public class SonarrCommand : ServiceCommand
{
private readonly IConfigurationLoader<SonarrConfiguration> _configLoader;
private readonly ILogger _log;
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(
ILogger log,
LoggingLevelSwitch loggingLevelSwitch,
ILogJanitor logJanitor,
IConfigurationLoader<SonarrConfiguration> configLoader,
Func<IReleaseProfileUpdater> profileUpdaterFactory,
Func<ISonarrQualityDefinitionUpdater> qualityUpdaterFactory)
: base(log, loggingLevelSwitch, logJanitor)
{
_log = log;
_configLoader = configLoader;
_profileUpdaterFactory = profileUpdaterFactory;
_qualityUpdaterFactory = qualityUpdaterFactory;
}
public SonarrCommand(
ILogger log,
LoggingLevelSwitch loggingLevelSwitch,
ILogJanitor logJanitor,
IConfigurationLoader<SonarrConfiguration> configLoader,
Func<IReleaseProfileUpdater> profileUpdaterFactory,
Func<ISonarrQualityDefinitionUpdater> qualityUpdaterFactory)
: base(log, loggingLevelSwitch, logJanitor)
{
_log = log;
_configLoader = configLoader;
_profileUpdaterFactory = profileUpdaterFactory;
_qualityUpdaterFactory = qualityUpdaterFactory;
}
public override string CacheStoragePath { get; } =
Path.Combine(AppPaths.AppDataPath, "cache", "sonarr");
public override string CacheStoragePath { get; } =
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)
{
await _qualityUpdaterFactory().Process(Preview, config);
}
if (config.QualityDefinition.HasValue)
{
await _qualityUpdaterFactory().Process(Preview, config);
}
}
catch (FlurlHttpException e)
{
_log.Error(e, "HTTP error while communicating with Sonarr");
ExitDueToFailure();
}
}
catch (FlurlHttpException e)
{
_log.Error(e, "HTTP error while communicating with Sonarr");
ExitDueToFailure();
}
}
}

@ -17,83 +17,82 @@ using TrashLib.Sonarr;
using TrashLib.Startup;
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<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)
{
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();
}
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 FluentValidation.Results;
namespace Trash.Config
namespace Trash.Config;
[Serializable]
public class ConfigurationException : Exception
{
[Serializable]
public class ConfigurationException : Exception
protected ConfigurationException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
protected ConfigurationException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
}
}
private ConfigurationException(string propertyName, Type deserializableType, IEnumerable<string> messages)
{
PropertyName = propertyName;
DeserializableType = deserializableType;
ErrorMessages = messages.ToList();
}
private ConfigurationException(string propertyName, Type deserializableType, IEnumerable<string> messages)
{
PropertyName = propertyName;
DeserializableType = deserializableType;
ErrorMessages = messages.ToList();
}
public ConfigurationException(string propertyName, Type deserializableType, string message)
: this(propertyName, deserializableType, new[] {message})
{
}
public ConfigurationException(string propertyName, Type deserializableType, string message)
: this(propertyName, deserializableType, new[] {message})
{
}
public ConfigurationException(string propertyName, Type deserializableType,
IEnumerable<ValidationFailure> validationFailures)
: this(propertyName, deserializableType, validationFailures.Select(e => e.ToString()))
{
}
public ConfigurationException(string propertyName, Type deserializableType,
IEnumerable<ValidationFailure> validationFailures)
: this(propertyName, deserializableType, validationFailures.Select(e => e.ToString()))
{
}
public IReadOnlyCollection<string> ErrorMessages { get; } = new List<string>();
public string PropertyName { get; } = "";
public Type DeserializableType { get; } = default!;
public IReadOnlyCollection<string> ErrorMessages { get; } = new List<string>();
public string PropertyName { get; } = "";
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 - ";
var builder = new StringBuilder(
$"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();
builder.Append(":" + delim);
builder.Append(string.Join(delim, ErrorMessages));
}
return builder.ToString();
}
}

@ -10,95 +10,94 @@ using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;
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>
where T : IServiceConfiguration
private readonly IConfigurationProvider _configProvider;
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;
private readonly IDeserializer _deserializer;
private readonly IFileSystem _fileSystem;
private readonly IValidator<T> _validator;
_configProvider = configProvider;
_fileSystem = fileSystem;
_validator = validator;
_deserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.WithTypeConverter(new YamlNullableEnumTypeConverter())
.WithObjectFactory(objectFactory)
.Build();
}
public ConfigurationLoader(
IConfigurationProvider configProvider,
IFileSystem fileSystem,
IObjectFactory objectFactory,
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)
{
using var stream = _fileSystem.File.OpenText(propertyName);
return LoadFromStream(stream, configSection);
}
public IEnumerable<T> Load(string propertyName, string configSection)
{
using var stream = _fileSystem.File.OpenText(propertyName);
return LoadFromStream(stream, configSection);
}
public IEnumerable<T> LoadFromStream(TextReader stream, string configSection)
{
var parser = new Parser(stream);
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);
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)
{
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();
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)
{
var result = _validator.Validate(config);
if (result is {IsValid: false})
{
throw new ConfigurationException(configSection, typeof(T), result.Errors);
}
validConfigs.Add(config);
}
throw new ConfigurationException(configSection, typeof(T), "There are no configured instances defined");
}
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;
yield return config;
throw new ConfigurationException(configSection, typeof(T), result.Errors);
}
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 TrashLib.Config;
namespace Trash.Config
namespace Trash.Config;
public interface IConfigurationLoader<out T>
where T : IServiceConfiguration
{
public interface IConfigurationLoader<out T>
where T : IServiceConfiguration
{
IEnumerable<T> Load(string propertyName, string configSection);
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.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)
{
_container = container;
}
public ObjectFactory(ILifetimeScope container)
{
_container = container;
}
public object Create(Type type)
{
return _container.IsRegistered(type) ? _container.Resolve(type) : _defaultFactory.Create(type);
}
public object Create(Type 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.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)
{
_fileSystem = fileSystem;
}
public LogJanitor(IFileSystem 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()
.OrderByDescending(f => f.Name)
.Skip(numberOfNewestToKeep))
{
file.Delete();
}
file.Delete();
}
}
}

@ -3,22 +3,21 @@ using Autofac;
using CliFx;
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()
{
_container = CompositionRoot.Setup();
return await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.SetExecutableName(ThisAssembly.AssemblyName)
.SetVersion($"v{ThisAssembly.AssemblyInformationalVersion}")
.UseTypeActivator(type => CliTypeActivator.ResolveType(_container, type))
.Build()
.RunAsync();
}
public static async Task<int> Main()
{
_container = CompositionRoot.Setup();
return await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.SetExecutableName(ThisAssembly.AssemblyName)
.SetVersion($"v{ThisAssembly.AssemblyInformationalVersion}")
.UseTypeActivator(type => CliTypeActivator.ResolveType(_container, type))
.Build()
.RunAsync();
}
}

@ -1,9 +1,8 @@
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 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)
=> new(true) {Mapping = entries.ToList()};
}
public static QualityProfileCustomFormatScoreMapping NewMappingWithReset(params FormatMappingEntry[] entries)
=> new(true) {Mapping = entries.ToList()};
}

@ -12,201 +12,200 @@ using TestLibrary.NSubstitute;
using TrashLib.Cache;
using TrashLib.Config;
namespace TrashLib.Tests.Cache
namespace TrashLib.Tests.Cache;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ServiceCacheTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ServiceCacheTest
private class Context
{
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>();
StoragePath = Substitute.For<ICacheStoragePath>();
ConfigProvider = Substitute.For<IConfigurationProvider>();
JsonSettings = new JsonSerializerSettings
Formatting = Formatting.Indented,
ContractResolver = new DefaultContractResolver
{
Formatting = Formatting.Indented,
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; }
}
NamingStrategy = new SnakeCaseNamingStrategy()
}
};
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 ObjectWithAttribute
{
public string TestValue { get; init; } = "";
}
private class ObjectWithoutAttribute
{
}
[CacheObjectName("invalid+name")]
private class ObjectWithAttributeInvalidChars
{
}
private const string ValidObjectName = "azAZ_09";
[Test]
public void Load_returns_null_when_file_does_not_exist()
{
var ctx = new Context();
ctx.Filesystem.File.Exists(Arg.Any<string>()).Returns(false);
[CacheObjectName(ValidObjectName)]
private class ObjectWithAttribute
{
public string TestValue { get; init; } = "";
}
var result = ctx.Cache.Load<ObjectWithAttribute>();
result.Should().BeNull();
}
[CacheObjectName("invalid+name")]
private class ObjectWithAttributeInvalidChars
{
}
[Test]
public void Loading_with_attribute_parses_correctly()
{
var ctx = new Context();
[Test]
public void Load_returns_null_when_file_does_not_exist()
{
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.Filesystem.File.Exists(Arg.Any<string>()).Returns(true);
ctx.Filesystem.File.ReadAllText(Arg.Any<string>())
.Returns(_ => JsonConvert.SerializeObject(testJson));
ctx.StoragePath.Path.Returns("testpath");
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();
obj!.TestValue.Should().Be("Foo");
ctx.Filesystem.File.Received().ReadAllText(Path.Combine("testpath", "be8fbc8f", $"{ValidObjectName}.json"));
}
var obj = ctx.Cache.Load<ObjectWithAttribute>();
[Test]
public void Loading_with_invalid_object_name_throws()
{
var ctx = new Context();
obj.Should().NotBeNull();
obj!.TestValue.Should().Be("Foo");
ctx.Filesystem.File.Received().ReadAllText(Path.Combine("testpath", "be8fbc8f", $"{ValidObjectName}.json"));
}
Action act = () => ctx.Cache.Load<ObjectWithAttributeInvalidChars>();
[Test]
public void Loading_with_invalid_object_name_throws()
{
var ctx = new Context();
act.Should()
.Throw<ArgumentException>()
.WithMessage("*'invalid+name' has unacceptable characters*");
}
Action act = () => ctx.Cache.Load<ObjectWithAttributeInvalidChars>();
[Test]
public void Loading_without_attribute_throws()
{
var ctx = new Context();
act.Should()
.Throw<ArgumentException>()
.WithMessage("*'invalid+name' has unacceptable characters*");
}
Action act = () => ctx.Cache.Load<ObjectWithoutAttribute>();
[Test]
public void Loading_without_attribute_throws()
{
var ctx = new Context();
act.Should()
.Throw<ArgumentException>()
.WithMessage("CacheObjectNameAttribute is missing*");
}
Action act = () => ctx.Cache.Load<ObjectWithoutAttribute>();
[Test]
public void Properties_are_saved_using_snake_case()
{
var ctx = new Context();
ctx.StoragePath.Path.Returns("testpath");
ctx.Cache.Save(new ObjectWithAttribute {TestValue = "Foo"});
act.Should()
.Throw<ArgumentException>()
.WithMessage("CacheObjectNameAttribute is missing*");
}
ctx.Filesystem.File.Received()
.WriteAllText(Arg.Any<string>(), Verify.That<string>(json => json.Should().Contain("\"test_value\"")));
}
[Test]
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]
public void Saving_with_attribute_parses_correctly()
{
var ctx = new Context();
ctx.Filesystem.File.Received()
.WriteAllText(Arg.Any<string>(), Verify.That<string>(json => json.Should().Contain("\"test_value\"")));
}
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.Filesystem.Directory.Received().CreateDirectory(expectedParentDirectory);
ctx.Cache.Save(new ObjectWithAttribute {TestValue = "Foo"});
dynamic expectedJson = new {TestValue = "Foo"};
var expectedPath = Path.Combine(expectedParentDirectory, $"{ValidObjectName}.json");
ctx.Filesystem.File.Received()
.WriteAllText(expectedPath, JsonConvert.SerializeObject(expectedJson, ctx.JsonSettings));
}
var expectedParentDirectory = Path.Combine("testpath", "be8fbc8f");
ctx.Filesystem.Directory.Received().CreateDirectory(expectedParentDirectory);
[Test]
public void Saving_with_invalid_object_name_throws()
{
var ctx = new Context();
dynamic expectedJson = new {TestValue = "Foo"};
var expectedPath = Path.Combine(expectedParentDirectory, $"{ValidObjectName}.json");
ctx.Filesystem.File.Received()
.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()
.Throw<ArgumentException>()
.WithMessage("*'invalid+name' has unacceptable characters*");
}
act.Should()
.Throw<ArgumentException>()
.WithMessage("*'invalid+name' has unacceptable characters*");
}
[Test]
public void Saving_without_attribute_throws()
{
var ctx = new Context();
[Test]
public void Saving_without_attribute_throws()
{
var ctx = new Context();
Action act = () => ctx.Cache.Save(new ObjectWithoutAttribute());
var act = () => ctx.Cache.Save(new ObjectWithoutAttribute());
act.Should()
.Throw<ArgumentException>()
.WithMessage("CacheObjectNameAttribute is missing*");
}
act.Should()
.Throw<ArgumentException>()
.WithMessage("CacheObjectNameAttribute is missing*");
}
[Test]
public void Switching_config_and_base_url_should_yield_different_cache_paths()
{
var ctx = new Context();
ctx.StoragePath.Path.Returns("testpath");
[Test]
public void Switching_config_and_base_url_should_yield_different_cache_paths()
{
var ctx = new Context();
ctx.StoragePath.Path.Returns("testpath");
var actualPaths = new List<string>();
var actualPaths = new List<string>();
dynamic testJson = new {TestValue = "Foo"};
ctx.Filesystem.File.Exists(Arg.Any<string>()).Returns(true);
ctx.Filesystem.File.ReadAllText(Arg.Do<string>(s => actualPaths.Add(s)))
.Returns(_ => JsonConvert.SerializeObject(testJson));
dynamic testJson = new {TestValue = "Foo"};
ctx.Filesystem.File.Exists(Arg.Any<string>()).Returns(true);
ctx.Filesystem.File.ReadAllText(Arg.Do<string>(s => actualPaths.Add(s)))
.Returns(_ => JsonConvert.SerializeObject(testJson));
ctx.Cache.Load<ObjectWithAttribute>();
ctx.Cache.Load<ObjectWithAttribute>();
// Change the active config & base URL so we get a different path
ctx.ConfigProvider.ActiveConfiguration = Substitute.For<IServiceConfiguration>();
ctx.ConfigProvider.ActiveConfiguration.BaseUrl.Returns("http://localhost:5678");
// Change the active config & base URL so we get a different path
ctx.ConfigProvider.ActiveConfiguration = Substitute.For<IServiceConfiguration>();
ctx.ConfigProvider.ActiveConfiguration.BaseUrl.Returns("http://localhost:5678");
ctx.Cache.Load<ObjectWithAttribute>();
ctx.Cache.Load<ObjectWithAttribute>();
actualPaths.Count.Should().Be(2);
actualPaths.Should().OnlyHaveUniqueItems();
}
actualPaths.Count.Should().Be(2);
actualPaths.Should().OnlyHaveUniqueItems();
}
[Test]
public void When_cache_file_is_empty_do_not_throw()
{
var ctx = new Context();
ctx.Filesystem.File.Exists(Arg.Any<string>()).Returns(true);
ctx.Filesystem.File.ReadAllText(Arg.Any<string>())
.Returns(_ => "");
[Test]
public void When_cache_file_is_empty_do_not_throw()
{
var ctx = new Context();
ctx.Filesystem.File.Exists(Arg.Any<string>()).Returns(true);
ctx.Filesystem.File.ReadAllText(Arg.Any<string>())
.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.Processors.PersistenceSteps;
namespace TrashLib.Tests.Radarr.CustomFormat
namespace TrashLib.Tests.Radarr.CustomFormat;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CachePersisterTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CachePersisterTest
private class Context
{
private class Context
public Context()
{
public Context()
{
var log = Substitute.For<ILogger>();
ServiceCache = Substitute.For<IServiceCache>();
Persister = new CachePersister(log, ServiceCache);
}
public CachePersister Persister { get; }
public IServiceCache ServiceCache { get; }
var log = Substitute.For<ILogger>();
ServiceCache = Substitute.For<IServiceCache>();
Persister = new CachePersister(log, ServiceCache);
}
private static ProcessedCustomFormatData QuickMakeCf(string cfName, string trashId, int cfId)
{
return new ProcessedCustomFormatData(cfName, trashId, new JObject())
{
CacheEntry = new TrashIdMapping(trashId, cfName) {CustomFormatId = cfId}
};
}
public CachePersister Persister { get; }
public IServiceCache ServiceCache { get; }
}
[TestCase(CustomFormatCache.LatestVersion - 1)]
[TestCase(CustomFormatCache.LatestVersion + 1)]
public void Set_loaded_cache_to_null_if_versions_mismatch(int versionToTest)
private static ProcessedCustomFormatData QuickMakeCf(string cfName, string trashId, int cfId)
{
return new ProcessedCustomFormatData(cfName, trashId, new JObject())
{
var ctx = new Context();
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();
}
CacheEntry = new TrashIdMapping(trashId, cfName) {CustomFormatId = cfId}
};
}
[Test]
public void Accept_loaded_cache_when_versions_match()
{
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();
}
[TestCase(CustomFormatCache.LatestVersion - 1)]
[TestCase(CustomFormatCache.LatestVersion + 1)]
public void Set_loaded_cache_to_null_if_versions_mismatch(int versionToTest)
{
var ctx = new Context();
[Test]
public void Cf_cache_is_valid_after_successful_load()
var testCfObj = new CustomFormatCache
{
var ctx = new Context();
var testCfObj = new CustomFormatCache();
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
Version = versionToTest,
TrashIdMappings = new Collection<TrashIdMapping> {new("", "", 5)}
};
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
ctx.Persister.Load();
ctx.Persister.CfCache.Should().BeNull();
}
ctx.Persister.Load();
ctx.Persister.CfCache.Should().BeSameAs(testCfObj);
}
[Test]
public void Accept_loaded_cache_when_versions_match()
{
var ctx = new Context();
[Test]
public void Cf_cache_returns_null_if_not_loaded()
var testCfObj = new CustomFormatCache
{
var ctx = new Context();
ctx.Persister.Load();
ctx.Persister.CfCache.Should().BeNull();
}
Version = CustomFormatCache.LatestVersion,
TrashIdMappings = new Collection<TrashIdMapping> {new("", "", 5)}
};
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
ctx.Persister.Load();
ctx.Persister.CfCache.Should().NotBeNull();
}
[Test]
public void Save_works_with_valid_cf_cache()
{
var ctx = new Context();
var testCfObj = new CustomFormatCache();
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
[Test]
public void Cf_cache_is_valid_after_successful_load()
{
var ctx = new Context();
var testCfObj = new CustomFormatCache();
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
ctx.Persister.Load();
ctx.Persister.Save();
ctx.Persister.Load();
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]
public void Saving_without_loading_does_nothing()
[Test]
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();
ctx.Persister.Save();
ctx.ServiceCache.DidNotReceive().Save(Arg.Any<object>());
}
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));
[Test]
public void Updating_overwrites_previous_cf_cache_and_updates_cf_data()
var customFormatData = new List<ProcessedCustomFormatData>
{
var ctx = new Context();
// 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});
}
new("", "trashid", new JObject()) {CacheEntry = new TrashIdMapping("trashid", "cfname", 10)}
};
[Test]
public void Updating_sets_cf_cache_without_loading()
ctx.Persister.Update(customFormatData);
ctx.Persister.CfCache.Should().BeEquivalentTo(new CustomFormatCache
{
var ctx = new Context();
ctx.Persister.Update(new List<ProcessedCustomFormatData>());
ctx.Persister.CfCache.Should().NotBeNull();
}
TrashIdMappings = new Collection<TrashIdMapping> {customFormatData[0].CacheEntry!}
});
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.GuideSteps;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors
namespace TrashLib.Tests.Radarr.CustomFormat.Processors;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class GuideProcessorTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class GuideProcessorTest
private class TestGuideProcessorSteps : IGuideProcessorSteps
{
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();
public IConfigStep Config { get; } = new ConfigStep();
public IQualityProfileStep QualityProfile { get; } = new QualityProfileStep();
Logger = new LoggerConfiguration()
.WriteTo.TestCorrelator()
.WriteTo.NUnitOutput()
.MinimumLevel.Debug()
.CreateLogger();
Data = new ResourceDataReader(typeof(GuideProcessorTest), "Data");
}
private class Context
{
public Context()
{
Logger = new LoggerConfiguration()
.WriteTo.TestCorrelator()
.WriteTo.NUnitOutput()
.MinimumLevel.Debug()
.CreateLogger();
public ILogger Logger { get; }
public ResourceDataReader Data { get; }
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; }
public ResourceDataReader Data { get; }
[Test]
[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);
public JObject ReadJson(string jsonFile) => JObject.Parse(ReadText(jsonFile));
}
// simulate guide data
guideService.GetCustomFormatJsonAsync().Returns(new[]
{
ctx.ReadText("ImportableCustomFormat1.json"),
ctx.ReadText("ImportableCustomFormat2.json"),
ctx.ReadText("NoScore.json"),
ctx.ReadText("WontBeInConfig.json")
});
[Test]
[SuppressMessage("Maintainability", "CA1506", Justification = "Designed to be a high-level integration test")]
public async Task Guide_processor_behaves_as_expected_with_normal_guide_data()
// Simulate user config in YAML
var config = new List<CustomFormatConfig>
{
var ctx = new Context();
var guideService = Substitute.For<IRadarrGuideService>();
var guideProcessor = new GuideProcessor(ctx.Logger, guideService, () => new TestGuideProcessorSteps());
new()
{
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
guideService.GetCustomFormatJsonAsync().Returns(new[]
await guideProcessor.BuildGuideDataAsync(config, null);
var expectedProcessedCustomFormatData = new List<ProcessedCustomFormatData>
{
new("Surround Sound", "43bb5f09c79641e7a22e48d440bd8868", ctx.ReadJson(
"ImportableCustomFormat1_Processed.json"))
{
ctx.ReadText("ImportableCustomFormat1.json"),
ctx.ReadText("ImportableCustomFormat2.json"),
ctx.ReadText("NoScore.json"),
ctx.ReadText("WontBeInConfig.json")
});
Score = 500
},
new("DTS-HD/DTS:X", "4eb3c272d48db8ab43c2c85283b69744", ctx.ReadJson(
"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
var config = new List<CustomFormatConfig>
guideProcessor.ConfigData.Should()
.BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{
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}
}
CustomFormats = expectedProcessedCustomFormatData,
QualityProfiles = config[0].QualityProfiles
},
new()
{
Names = new List<string> {"no score", "not in guide 2"},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile3"},
new() {Name = "profile4", Score = 5678}
}
CustomFormats = expectedProcessedCustomFormatData.GetRange(2, 1),
QualityProfiles = config[1].QualityProfiles
}
};
}, 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"),
("No Score", "abc", "profile3")
});
guideProcessor.CustomFormatsNotInGuide.Should().Equal(new List<string>
{
"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>());
}
"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.Processors.GuideSteps;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.GuideSteps
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.GuideSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigStepTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigStepTest
[Test]
public void Cache_names_are_used_instead_of_name_in_json_data()
{
[Test]
public void Cache_names_are_used_instead_of_name_in_json_data()
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
},
new("name3", "id3", JObject.FromObject(new {name = "name3"}))
{
CacheEntry = new TrashIdMapping("id3", "name1")
}
};
Score = 100
},
new("name3", "id3", JObject.FromObject(new {name = "name3"}))
{
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();
processor.Process(testProcessedCfs, testConfig);
var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{
CustomFormats = new List<ProcessedCustomFormatData>
{testProcessedCfs[1]}
}
}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
}
processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{
CustomFormats = new List<ProcessedCustomFormatData>
{testProcessedCfs[1]}
}
}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
}
[Test]
public void Custom_formats_missing_from_config_are_skipped()
[Test]
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();
processor.Process(testProcessedCfs, testConfig);
var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
processor.CustomFormatsNotInGuide.Should().BeEmpty();
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))
.WhenTypeIs<JToken>());
}
}
}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
}
[Test]
public void Custom_formats_missing_from_guide_are_added_to_not_in_guide_list()
[Test]
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();
processor.Process(testProcessedCfs, testConfig);
var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.Should().BeEquivalentTo(new List<string> {"name3"}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
processor.CustomFormatsNotInGuide.Should().BeEquivalentTo(new List<string> {"name3"}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
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))
.WhenTypeIs<JToken>());
}
}
}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
}
[Test]
public void Duplicate_config_name_and_id_are_ignored()
[Test]
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();
processor.Process(testProcessedCfs, testConfig);
var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{
new()
{
CustomFormats = new List<ProcessedCustomFormatData> {testProcessedCfs[0]}
}
});
}
CustomFormats = new List<ProcessedCustomFormatData> {testProcessedCfs[0]}
}
});
}
[Test]
public void Duplicate_config_names_are_ignored()
[Test]
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[]
{
new() {Names = new List<string> {"name1", "name1"}}
};
var testConfig = new CustomFormatConfig[]
{
new() {Names = new List<string> {"name1", "name1"}}
};
var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig);
var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{
new()
{
CustomFormats = new List<ProcessedCustomFormatData> {testProcessedCfs[0]}
}
});
}
CustomFormats = new List<ProcessedCustomFormatData> {testProcessedCfs[0]}
}
});
}
[Test]
public void Find_custom_formats_by_name_and_trash_id()
[Test]
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
},
new("name3", "id3", JObject.FromObject(new {name = "name3"})),
new("name4", "id4", new JObject())
};
Score = 100
},
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"},
TrashIds = new List<string> {"id1", "id4"},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1", Score = 50}
}
new() {Name = "profile1", Score = 50}
}
};
}
};
var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig);
var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{
CustomFormats = testProcessedCfs,
QualityProfiles = testConfig[0].QualityProfiles
}
}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
}
processor.CustomFormatsNotInGuide.Should().BeEmpty();
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
{
new()
{
CustomFormats = testProcessedCfs,
QualityProfiles = testConfig[0].QualityProfiles
}
}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());
}
}

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

@ -8,127 +8,126 @@ using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Models;
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]
[Parallelizable(ParallelScope.All)]
public class QualityProfileStepTest
[Test]
public void No_score_used_if_no_score_in_config_or_guide()
{
[Test]
public void No_score_used_if_no_score_in_config_or_guide()
var testConfigData = new List<ProcessedConfigData>
{
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("name1", "id1", new JObject()) {Score = null}
},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1"}
}
new() {Name = "profile1"}
}
};
}
};
var processor = new QualityProfileStep();
processor.Process(testConfigData);
var processor = new QualityProfileStep();
processor.Process(testConfigData);
processor.ProfileScores.Should().BeEmpty();
processor.CustomFormatsWithoutScore.Should().Equal(("name1", "id1", "profile1"));
}
processor.ProfileScores.Should().BeEmpty();
processor.CustomFormatsWithoutScore.Should().Equal(("name1", "id1", "profile1"));
}
[Test]
public void Overwrite_score_from_guide_if_config_defines_score()
[Test]
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}
},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1", Score = 50}
}
new("", "id1", new JObject()) {Score = 100}
},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1", Score = 50}
}
};
}
};
var processor = new QualityProfileStep();
processor.Process(testConfigData);
var processor = new QualityProfileStep();
processor.Process(testConfigData);
processor.ProfileScores.Should()
.ContainKey("profile1").WhoseValue.Should()
.BeEquivalentTo(
CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats.First(), 50)));
processor.ProfileScores.Should()
.ContainKey("profile1").WhoseValue.Should()
.BeEquivalentTo(
CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats.First(), 50)));
processor.CustomFormatsWithoutScore.Should().BeEmpty();
}
processor.CustomFormatsWithoutScore.Should().BeEmpty();
}
[Test]
public void Use_guide_score_if_no_score_in_config()
[Test]
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}
},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1"},
new() {Name = "profile2", Score = null}
}
new("", "id1", new JObject()) {Score = 100}
},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1"},
new() {Name = "profile2", Score = null}
}
};
}
};
var processor = new QualityProfileStep();
processor.Process(testConfigData);
var processor = new QualityProfileStep();
processor.Process(testConfigData);
var expectedScoreEntries =
CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats.First(), 100));
var expectedScoreEntries =
CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats.First(), 100));
processor.ProfileScores.Should().BeEquivalentTo(
new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{"profile1", expectedScoreEntries},
{"profile2", expectedScoreEntries}
});
processor.ProfileScores.Should().BeEquivalentTo(
new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{"profile1", expectedScoreEntries},
{"profile2", expectedScoreEntries}
});
processor.CustomFormatsWithoutScore.Should().BeEmpty();
}
processor.CustomFormatsWithoutScore.Should().BeEmpty();
}
[Test]
public void Zero_score_is_not_ignored()
[Test]
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("name1", "id1", new JObject()) {Score = 0}
},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1"}
}
new() {Name = "profile1"}
}
};
}
};
var processor = new QualityProfileStep();
processor.Process(testConfigData);
var processor = new QualityProfileStep();
processor.Process(testConfigData);
processor.ProfileScores.Should()
.ContainKey("profile1").WhoseValue.Should()
.BeEquivalentTo(CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats.First(), 0)));
processor.ProfileScores.Should()
.ContainKey("profile1").WhoseValue.Should()
.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.Processors;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors
namespace TrashLib.Tests.Radarr.CustomFormat.Processors;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class PersistenceProcessorTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class PersistenceProcessorTest
[Test]
public void Custom_formats_are_deleted_if_deletion_option_is_enabled_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 = 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]
public void Custom_formats_are_deleted_if_deletion_option_is_enabled_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 = 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()
{
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>>());
}
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.Processors.PersistenceSteps;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CustomFormatApiPersistenceStepTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class CustomFormatApiPersistenceStepTest
private static ProcessedCustomFormatData QuickMakeCf(string cfName, string trashId, int cfId)
{
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]
public async Task All_api_operations_behave_normally()
{
var transactions = new CustomFormatTransactionData();
transactions.NewCustomFormats.Add(QuickMakeCf("cfname1", "trashid1", 1));
transactions.UpdatedCustomFormats.Add(QuickMakeCf("cfname2", "trashid2", 2));
transactions.UnchangedCustomFormats.Add(QuickMakeCf("cfname3", "trashid3", 3));
transactions.DeletedCustomFormatIds.Add(new TrashIdMapping("trashid4", "cfname4") {CustomFormatId = 4});
[Test]
public async Task All_api_operations_behave_normally()
{
var transactions = new CustomFormatTransactionData();
transactions.NewCustomFormats.Add(QuickMakeCf("cfname1", "trashid1", 1));
transactions.UpdatedCustomFormats.Add(QuickMakeCf("cfname2", "trashid2", 2));
transactions.UnchangedCustomFormats.Add(QuickMakeCf("cfname3", "trashid3", 3));
transactions.DeletedCustomFormatIds.Add(new TrashIdMapping("trashid4", "cfname4") {CustomFormatId = 4});
var api = Substitute.For<ICustomFormatService>();
var api = Substitute.For<ICustomFormatService>();
var processor = new CustomFormatApiPersistenceStep();
await processor.Process(api, transactions);
var processor = new CustomFormatApiPersistenceStep();
await processor.Process(api, transactions);
Received.InOrder(() =>
{
api.CreateCustomFormat(transactions.NewCustomFormats.First());
api.UpdateCustomFormat(transactions.UpdatedCustomFormats.First());
api.DeleteCustomFormat(4);
});
}
Received.InOrder(() =>
{
api.CreateCustomFormat(transactions.NewCustomFormats.First());
api.UpdateCustomFormat(transactions.UpdatedCustomFormats.First());
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]
[Parallelizable(ParallelScope.All)]
public class JsonTransactionStepTest
[TestCase(1, "cf2")]
[TestCase(2, "cf1")]
[TestCase(null, "cf1")]
public void Updates_using_combination_of_id_and_name(int? id, string guideCfName)
{
[TestCase(1, "cf2")]
[TestCase(2, "cf1")]
[TestCase(null, "cf1")]
public void Updates_using_combination_of_id_and_name(int? id, string guideCfName)
{
const string radarrCfData = @"{
const string radarrCfData = @"{
'id': 1,
'name': 'cf1',
'specifications': [{
@ -59,7 +59,7 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}]
}]
}";
var guideCfData = JObject.Parse(@"{
var guideCfData = JObject.Parse(@"{
'name': 'cf1',
'specifications': [{
'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>
{
new(guideCfName, "", guideCfData) {CacheEntry = cacheEntry}
};
var guideCfs = new List<ProcessedCustomFormatData>
{
new(guideCfName, "", guideCfData) {CacheEntry = cacheEntry}
};
var processor = new JsonTransactionStep();
processor.Process(guideCfs, new[] {JObject.Parse(radarrCfData)});
var processor = new JsonTransactionStep();
processor.Process(guideCfs, new[] {JObject.Parse(radarrCfData)});
var expectedTransactions = new CustomFormatTransactionData();
expectedTransactions.UpdatedCustomFormats.Add(guideCfs[0]);
processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
var expectedTransactions = new CustomFormatTransactionData();
expectedTransactions.UpdatedCustomFormats.Add(guideCfs[0]);
processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
const string expectedJsonData = @"{
const string expectedJsonData = @"{
'id': 1,
'name': 'cf1',
'specifications': [{
@ -95,14 +95,14 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}]
}]
}";
processor.Transactions.UpdatedCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJsonData), op => op.Using(new JsonEquivalencyStep()));
}
processor.Transactions.UpdatedCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJsonData), op => op.Using(new JsonEquivalencyStep()));
}
[Test]
public void Combination_of_create_update_and_unchanged_and_verify_proper_json_merging()
{
const string radarrCfData = @"[{
[Test]
public void Combination_of_create_update_and_unchanged_and_verify_proper_json_merging()
{
const string radarrCfData = @"[{
'id': 1,
'name': 'user_defined',
'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',
'specifications': [{
'name': 'spec5',
@ -172,23 +172,23 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}]
}]");
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData);
var guideCfs = new List<ProcessedCustomFormatData>
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData);
var guideCfs = new List<ProcessedCustomFormatData>
{
new("created", "", guideCfData![0]),
new("updated_different_name", "", guideCfData[1])
{
new("created", "", guideCfData![0]),
new("updated_different_name", "", guideCfData[1])
{
CacheEntry = new TrashIdMapping("", "", 2)
},
new("no_change", "", guideCfData[2])
};
CacheEntry = new TrashIdMapping("", "", 2)
},
new("no_change", "", guideCfData[2])
};
var processor = new JsonTransactionStep();
processor.Process(guideCfs, radarrCfs!);
var processor = new JsonTransactionStep();
processor.Process(guideCfs, radarrCfs!);
var expectedJson = new[]
{
@"{
var expectedJson = new[]
{
@"{
'name': 'created',
'specifications': [{
'name': 'spec5',
@ -198,7 +198,7 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}]
}]
}",
@"{
@"{
'id': 2,
'name': 'updated_different_name',
'specifications': [{
@ -219,7 +219,7 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}]
}]
}",
@"{
@"{
'id': 3,
'name': 'no_change',
'specifications': [{
@ -231,28 +231,28 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}]
}]
}"
};
};
var expectedTransactions = new CustomFormatTransactionData();
expectedTransactions.NewCustomFormats.Add(guideCfs[0]);
expectedTransactions.UpdatedCustomFormats.Add(guideCfs[1]);
expectedTransactions.UnchangedCustomFormats.Add(guideCfs[2]);
processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
var expectedTransactions = new CustomFormatTransactionData();
expectedTransactions.NewCustomFormats.Add(guideCfs[0]);
expectedTransactions.UpdatedCustomFormats.Add(guideCfs[1]);
expectedTransactions.UnchangedCustomFormats.Add(guideCfs[2]);
processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
processor.Transactions.NewCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJson[0]), op => op.Using(new JsonEquivalencyStep()));
processor.Transactions.NewCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJson[0]), op => op.Using(new JsonEquivalencyStep()));
processor.Transactions.UpdatedCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJson[1]), op => op.Using(new JsonEquivalencyStep()));
processor.Transactions.UpdatedCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJson[1]), op => op.Using(new JsonEquivalencyStep()));
processor.Transactions.UnchangedCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJson[2]), op => op.Using(new JsonEquivalencyStep()));
}
processor.Transactions.UnchangedCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJson[2]), op => op.Using(new JsonEquivalencyStep()));
}
[Test]
public void Deletes_happen_before_updates()
{
const string radarrCfData = @"[{
[Test]
public void Deletes_happen_before_updates()
{
const string radarrCfData = @"[{
'id': 1,
'name': 'updated',
'specifications': [{
@ -275,7 +275,7 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}]
}]
}]";
var guideCfData = JObject.Parse(@"{
var guideCfData = JObject.Parse(@"{
'name': 'updated',
'specifications': [{
'name': 'spec2',
@ -284,23 +284,23 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}
}]
}");
var deletedCfsInCache = new List<TrashIdMapping>
{
new("", "") {CustomFormatId = 2}
};
var deletedCfsInCache = new List<TrashIdMapping>
{
new("", "") {CustomFormatId = 2}
};
var guideCfs = new List<ProcessedCustomFormatData>
{
new("updated", "", guideCfData) {CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 1}}
};
var guideCfs = new List<ProcessedCustomFormatData>
{
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();
processor.Process(guideCfs, radarrCfs!);
processor.RecordDeletions(deletedCfsInCache, radarrCfs!);
var processor = new JsonTransactionStep();
processor.Process(guideCfs, radarrCfs!);
processor.RecordDeletions(deletedCfsInCache, radarrCfs!);
var expectedJson = @"{
var expectedJson = @"{
'id': 1,
'name': 'updated',
'specifications': [{
@ -311,19 +311,19 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}]
}]
}";
var expectedTransactions = new CustomFormatTransactionData();
expectedTransactions.DeletedCustomFormatIds.Add(new TrashIdMapping("", "", 2));
expectedTransactions.UpdatedCustomFormats.Add(guideCfs[0]);
processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
var expectedTransactions = new CustomFormatTransactionData();
expectedTransactions.DeletedCustomFormatIds.Add(new TrashIdMapping("", "", 2));
expectedTransactions.UpdatedCustomFormats.Add(guideCfs[0]);
processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
processor.Transactions.UpdatedCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJson), op => op.Using(new JsonEquivalencyStep()));
}
processor.Transactions.UpdatedCustomFormats.First().Json.Should()
.BeEquivalentTo(JObject.Parse(expectedJson), op => op.Using(new JsonEquivalencyStep()));
}
[Test]
public void Only_delete_correct_cfs()
{
const string radarrCfData = @"[{
[Test]
public void Only_delete_correct_cfs()
{
const string radarrCfData = @"[{
'id': 1,
'name': 'not_deleted',
'specifications': [{
@ -347,26 +347,26 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}]
}]
}]";
var deletedCfsInCache = new List<TrashIdMapping>
{
new("testtrashid", "testname") {CustomFormatId = 2},
new("", "not_deleted") {CustomFormatId = 3}
};
var deletedCfsInCache = new List<TrashIdMapping>
{
new("testtrashid", "testname") {CustomFormatId = 2},
new("", "not_deleted") {CustomFormatId = 3}
};
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData);
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData);
var processor = new JsonTransactionStep();
processor.RecordDeletions(deletedCfsInCache, radarrCfs!);
var processor = new JsonTransactionStep();
processor.RecordDeletions(deletedCfsInCache, radarrCfs!);
var expectedTransactions = new CustomFormatTransactionData();
expectedTransactions.DeletedCustomFormatIds.Add(new TrashIdMapping("testtrashid", "testname", 2));
processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
}
var expectedTransactions = new CustomFormatTransactionData();
expectedTransactions.DeletedCustomFormatIds.Add(new TrashIdMapping("testtrashid", "testname", 2));
processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
}
[Test]
public void Updated_and_unchanged_custom_formats_have_cache_entry_set_when_there_is_no_cache()
{
const string radarrCfData = @"[{
[Test]
public void Updated_and_unchanged_custom_formats_have_cache_entry_set_when_there_is_no_cache()
{
const string radarrCfData = @"[{
'id': 1,
'name': 'updated',
'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',
'specifications': [{
'name': 'spec2',
@ -407,21 +407,20 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
}]
}]");
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData);
var guideCfs = new List<ProcessedCustomFormatData>
{
new("updated", "", guideCfData![0]),
new("no_change", "", guideCfData[1])
};
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData);
var guideCfs = new List<ProcessedCustomFormatData>
{
new("updated", "", guideCfData![0]),
new("no_change", "", guideCfData[1])
};
var processor = new JsonTransactionStep();
processor.Process(guideCfs, radarrCfs!);
var processor = new JsonTransactionStep();
processor.Process(guideCfs, radarrCfs!);
processor.Transactions.UpdatedCustomFormats.First().CacheEntry.Should()
.BeEquivalentTo(new TrashIdMapping("", "updated", 1));
processor.Transactions.UpdatedCustomFormats.First().CacheEntry.Should()
.BeEquivalentTo(new TrashIdMapping("", "updated", 1));
processor.Transactions.UnchangedCustomFormats.First().CacheEntry.Should()
.BeEquivalentTo(new TrashIdMapping("", "no_change", 2));
}
processor.Transactions.UnchangedCustomFormats.First().CacheEntry.Should()
.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.Processors.PersistenceSteps;
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class QualityProfileApiPersistenceStepTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class QualityProfileApiPersistenceStepTest
[Test]
public void Do_not_invoke_api_if_no_scores_to_update()
{
[Test]
public void Do_not_invoke_api_if_no_scores_to_update()
{
const string radarrQualityProfileData = @"[{
const string radarrQualityProfileData = @"[{
'name': 'profile1',
'formatItems': [{
'format': 1,
@ -42,50 +42,50 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
'id': 1
}]";
var api = Substitute.For<IQualityProfileService>();
api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var api = Substitute.For<IQualityProfileService>();
api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{
{
"profile1", CfTestUtils.NewMapping(
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
{
CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 4}
}, 100))
}
};
"profile1", CfTestUtils.NewMapping(
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
{
CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 4}
}, 100))
}
};
var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores);
var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores);
api.DidNotReceive().UpdateQualityProfile(Arg.Any<JObject>(), Arg.Any<int>());
}
api.DidNotReceive().UpdateQualityProfile(Arg.Any<JObject>(), Arg.Any<int>());
}
[Test]
public void Invalid_quality_profile_names_are_reported()
{
const string radarrQualityProfileData = @"[{'name': 'profile1'}]";
[Test]
public void Invalid_quality_profile_names_are_reported()
{
const string radarrQualityProfileData = @"[{'name': 'profile1'}]";
var api = Substitute.For<IQualityProfileService>();
api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var api = Substitute.For<IQualityProfileService>();
api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{"wrong_profile_name", CfTestUtils.NewMapping()}
};
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{"wrong_profile_name", CfTestUtils.NewMapping()}
};
var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores);
var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores);
processor.InvalidProfileNames.Should().Equal("wrong_profile_name");
processor.UpdatedScores.Should().BeEmpty();
}
processor.InvalidProfileNames.Should().Equal("wrong_profile_name");
processor.UpdatedScores.Should().BeEmpty();
}
[Test]
public void Reset_scores_for_unmatched_cfs_if_enabled()
{
const string radarrQualityProfileData = @"[{
[Test]
public void Reset_scores_for_unmatched_cfs_if_enabled()
{
const string radarrQualityProfileData = @"[{
'name': 'profile1',
'formatItems': [{
'format': 1,
@ -106,42 +106,42 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
'id': 1
}]";
var api = Substitute.For<IQualityProfileService>();
api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var api = Substitute.For<IQualityProfileService>();
api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{
{
"profile1", CfTestUtils.NewMappingWithReset(
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
{
CacheEntry = new TrashIdMapping("", "", 2)
}, 100))
}
};
"profile1", CfTestUtils.NewMappingWithReset(
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
{
CacheEntry = new TrashIdMapping("", "", 2)
}, 100))
}
};
var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores);
var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores);
processor.InvalidProfileNames.Should().BeEmpty();
processor.UpdatedScores.Should()
.ContainKey("profile1").WhoseValue.Should()
.BeEquivalentTo(new List<UpdatedFormatScore>
{
new("cf1", 0, FormatScoreUpdateReason.Reset),
new("cf2", 100, FormatScoreUpdateReason.Updated),
new("cf3", 0, FormatScoreUpdateReason.Reset)
});
processor.InvalidProfileNames.Should().BeEmpty();
processor.UpdatedScores.Should()
.ContainKey("profile1").WhoseValue.Should()
.BeEquivalentTo(new List<UpdatedFormatScore>
{
new("cf1", 0, FormatScoreUpdateReason.Reset),
new("cf2", 100, FormatScoreUpdateReason.Updated),
new("cf3", 0, FormatScoreUpdateReason.Reset)
});
api.Received().UpdateQualityProfile(
Verify.That<JObject>(j => j["formatItems"]!.Children().Should().HaveCount(3)),
Arg.Any<int>());
}
api.Received().UpdateQualityProfile(
Verify.That<JObject>(j => j["formatItems"]!.Children().Should().HaveCount(3)),
Arg.Any<int>());
}
[Test]
public void Scores_are_set_in_quality_profile()
{
const string radarrQualityProfileData = @"[{
[Test]
public void Scores_are_set_in_quality_profile()
{
const string radarrQualityProfileData = @"[{
'name': 'profile1',
'upgradeAllowed': false,
'cutoff': 20,
@ -182,35 +182,35 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
'id': 1
}]";
var api = Substitute.For<IQualityProfileService>();
api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var api = Substitute.For<IQualityProfileService>();
api.GetQualityProfiles()!.Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{
{
"profile1", CfTestUtils.NewMapping(
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
{
// First match by ID
CacheEntry = new TrashIdMapping("", "", 4)
}, 100),
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
{
// Should NOT match because we do not use names to assign scores
CacheEntry = new TrashIdMapping("", "BR-DISK")
}, 101),
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
{
// Second match by ID
CacheEntry = new TrashIdMapping("", "", 1)
}, 102))
}
};
"profile1", CfTestUtils.NewMapping(
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
{
// First match by ID
CacheEntry = new TrashIdMapping("", "", 4)
}, 100),
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
{
// Should NOT match because we do not use names to assign scores
CacheEntry = new TrashIdMapping("", "BR-DISK")
}, 101),
new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject())
{
// Second match by ID
CacheEntry = new TrashIdMapping("", "", 1)
}, 102))
}
};
var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores);
var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores);
var expectedProfileJson = JObject.Parse(@"{
var expectedProfileJson = JObject.Parse(@"{
'name': 'profile1',
'upgradeAllowed': false,
'cutoff': 20,
@ -251,16 +251,15 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
'id': 1
}");
api.Received()
.UpdateQualityProfile(Verify.That<JObject>(a => a.Should().BeEquivalentTo(expectedProfileJson)), 1);
processor.InvalidProfileNames.Should().BeEmpty();
processor.UpdatedScores.Should()
.ContainKey("profile1").WhoseValue.Should()
.BeEquivalentTo(new List<UpdatedFormatScore>
{
new("3D", 100, FormatScoreUpdateReason.Updated),
new("asdf2", 102, FormatScoreUpdateReason.Updated)
});
}
api.Received()
.UpdateQualityProfile(Verify.That<JObject>(a => a.Should().BeEquivalentTo(expectedProfileJson)), 1);
processor.InvalidProfileNames.Should().BeEmpty();
processor.UpdatedScores.Should()
.ContainKey("profile1").WhoseValue.Should()
.BeEquivalentTo(new List<UpdatedFormatScore>
{
new("3D", 100, FormatScoreUpdateReason.Updated),
new("asdf2", 102, FormatScoreUpdateReason.Updated)
});
}
}

@ -2,120 +2,119 @@ using FluentAssertions;
using NUnit.Framework;
using TrashLib.Radarr.QualityDefinition;
namespace TrashLib.Tests.Radarr.QualityDefinition
namespace TrashLib.Tests.Radarr.QualityDefinition;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class RadarrQualityDataTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class RadarrQualityDataTest
private static readonly object[] PreferredTestValues =
{
private static readonly object[] PreferredTestValues =
{
new object?[] {100m, 100m, false},
new object?[] {100m, 101m, true},
new object?[] {100m, 98m, true},
new object?[] {100m, null, true},
new object?[] {RadarrQualityData.PreferredUnlimitedThreshold, null, false},
new object?[] {RadarrQualityData.PreferredUnlimitedThreshold - 1, null, true},
new object?[]
{RadarrQualityData.PreferredUnlimitedThreshold, RadarrQualityData.PreferredUnlimitedThreshold, true}
};
new object?[] {100m, 100m, false},
new object?[] {100m, 101m, true},
new object?[] {100m, 98m, true},
new object?[] {100m, null, true},
new object?[] {RadarrQualityData.PreferredUnlimitedThreshold, null, false},
new object?[] {RadarrQualityData.PreferredUnlimitedThreshold - 1, null, true},
new object?[]
{RadarrQualityData.PreferredUnlimitedThreshold, RadarrQualityData.PreferredUnlimitedThreshold, true}
};
[TestCaseSource(nameof(PreferredTestValues))]
public void PreferredDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal? radarrValue,
bool isDifferent)
{
var data = new RadarrQualityData {Preferred = guideValue};
data.IsPreferredDifferent(radarrValue)
.Should().Be(isDifferent);
}
[TestCaseSource(nameof(PreferredTestValues))]
public void PreferredDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal? radarrValue,
bool isDifferent)
{
var data = new RadarrQualityData {Preferred = guideValue};
data.IsPreferredDifferent(radarrValue)
.Should().Be(isDifferent);
}
private static readonly object[] InterpolatedPreferredTestParams =
private static readonly object[] InterpolatedPreferredTestParams =
{
new[]
{
new[]
{
400m,
1.0m,
RadarrQualityData.PreferredUnlimitedThreshold
},
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)
400m,
1.0m,
RadarrQualityData.PreferredUnlimitedThreshold
},
new[]
{
var data = new RadarrQualityData {Min = 0, Max = max};
data.InterpolatedPreferred(ratio).Should().Be(expectedResult);
}
[Test]
public void AnnotatedPreferred_OutsideThreshold_EqualsSameValueWithUnlimited()
RadarrQualityData.PreferredUnlimitedThreshold,
1.0m,
RadarrQualityData.PreferredUnlimitedThreshold
},
new[]
{
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold;
var data = new RadarrQualityData {Preferred = testVal};
data.AnnotatedPreferred.Should().Be($"{testVal} (Unlimited)");
}
[Test]
public void AnnotatedPreferred_WithinThreshold_EqualsSameStringValue()
RadarrQualityData.PreferredUnlimitedThreshold - 1m,
1.0m,
RadarrQualityData.PreferredUnlimitedThreshold - 1m
},
new[]
{
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold - 1;
var data = new RadarrQualityData {Preferred = testVal};
data.AnnotatedPreferred.Should().Be($"{testVal}");
}
[Test]
public void Preferred_AboveThreshold_EqualsSameValue()
10m,
0m,
0m
},
new[]
{
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold + 1;
var data = new RadarrQualityData {Preferred = testVal};
data.Preferred.Should().Be(testVal);
100m,
0.5m,
50m
}
};
[Test]
public void PreferredForApi_AboveThreshold_EqualsNull()
{
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold + 1;
var data = new RadarrQualityData {Preferred = testVal};
data.PreferredForApi.Should().Be(null);
}
[TestCaseSource(nameof(InterpolatedPreferredTestParams))]
public void InterpolatedPreferred_VariousValues_ExpectedResults(decimal max, decimal ratio,
decimal expectedResult)
{
var data = new RadarrQualityData {Min = 0, Max = max};
data.InterpolatedPreferred(ratio).Should().Be(expectedResult);
}
[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 AnnotatedPreferred_OutsideThreshold_EqualsSameValueWithUnlimited()
{
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold;
var data = new RadarrQualityData {Preferred = testVal};
data.AnnotatedPreferred.Should().Be($"{testVal} (Unlimited)");
}
[Test]
public void PreferredForApi_LowestWithinThreshold_EqualsSameValue()
{
var data = new RadarrQualityData {Preferred = 0};
data.PreferredForApi.Should().Be(0);
}
[Test]
public void AnnotatedPreferred_WithinThreshold_EqualsSameStringValue()
{
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold - 1;
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.QualityDefinition;
namespace TrashLib.Tests.Radarr
namespace TrashLib.Tests.Radarr;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class RadarrConfigurationTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class RadarrConfigurationTest
{
private IContainer _container = default!;
private IContainer _container = default!;
[OneTimeSetUp]
public void Setup()
{
var builder = new ContainerBuilder();
builder.RegisterModule<ConfigAutofacModule>();
builder.RegisterModule<RadarrAutofacModule>();
_container = builder.Build();
}
[OneTimeSetUp]
public void Setup()
{
var builder = new ContainerBuilder();
builder.RegisterModule<ConfigAutofacModule>();
builder.RegisterModule<RadarrAutofacModule>();
_container = builder.Build();
}
private static readonly TestCaseData[] NameOrIdsTestData =
{
new(new Collection<string> {"name"}, new Collection<string>()),
new(new Collection<string>(), new Collection<string> {"trash_id"})
};
private static readonly TestCaseData[] NameOrIdsTestData =
{
new(new Collection<string> {"name"}, new Collection<string>()),
new(new Collection<string>(), new Collection<string> {"trash_id"})
};
[TestCaseSource(nameof(NameOrIdsTestData))]
public void Custom_format_is_valid_with_one_of_either_names_or_trash_id(Collection<string> namesList,
Collection<string> trashIdsList)
[TestCaseSource(nameof(NameOrIdsTestData))]
public void Custom_format_is_valid_with_one_of_either_names_or_trash_id(Collection<string> namesList,
Collection<string> trashIdsList)
{
var config = new RadarrConfiguration
{
var config = new RadarrConfiguration
ApiKey = "required value",
BaseUrl = "required value",
CustomFormats = new List<CustomFormatConfig>
{
ApiKey = "required value",
BaseUrl = "required value",
CustomFormats = new List<CustomFormatConfig>
{
new() {Names = namesList, TrashIds = trashIdsList}
}
};
new() {Names = namesList, TrashIds = trashIdsList}
}
};
var validator = _container.Resolve<IValidator<RadarrConfiguration>>();
var result = validator.Validate(config);
var validator = _container.Resolve<IValidator<RadarrConfiguration>>();
var result = validator.Validate(config);
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
[Test]
public void Validation_fails_for_all_missing_required_properties()
{
// default construct which should yield default values (invalid) for all required properties
var config = new RadarrConfiguration();
var validator = _container.Resolve<IValidator<RadarrConfiguration>>();
[Test]
public void Validation_fails_for_all_missing_required_properties()
{
// default construct which should yield default values (invalid) for all required properties
var config = new RadarrConfiguration();
var validator = _container.Resolve<IValidator<RadarrConfiguration>>();
var result = validator.Validate(config);
var result = validator.Validate(config);
var expectedErrorMessageSubstrings = new[]
{
"Property 'base_url' is required",
"Property 'api_key' is required",
"'custom_formats' elements must contain at least one element in either 'names' or 'trash_ids'",
"'name' is required for elements under 'quality_profiles'",
"'type' is required for 'quality_definition'"
};
var expectedErrorMessageSubstrings = new[]
{
"Property 'base_url' is required",
"Property 'api_key' is required",
"'custom_formats' elements must contain at least one element in either 'names' or 'trash_ids'",
"'name' is required for elements under 'quality_profiles'",
"'type' is required for 'quality_definition'"
};
result.IsValid.Should().BeFalse();
result.Errors.Select(e => e.ErrorMessage).Should()
.OnlyContain(x => expectedErrorMessageSubstrings.Any(x.Contains));
}
result.IsValid.Should().BeFalse();
result.Errors.Select(e => e.ErrorMessage).Should()
.OnlyContain(x => expectedErrorMessageSubstrings.Any(x.Contains));
}
[Test]
public void Validation_succeeds_when_no_missing_required_properties()
[Test]
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",
BaseUrl = "required value",
CustomFormats = new List<CustomFormatConfig>
new()
{
new()
Names = new List<string> {"required value"},
QualityProfiles = new List<QualityProfileConfig>
{
Names = new List<string> {"required value"},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "required value"}
}
new() {Name = "required value"}
}
},
QualityDefinition = new QualityDefinitionConfig
{
Type = RadarrQualityDefinitionType.Movie
}
};
},
QualityDefinition = new QualityDefinitionConfig
{
Type = RadarrQualityDefinitionType.Movie
}
};
var validator = _container.Resolve<IValidator<RadarrConfiguration>>();
var result = validator.Validate(config);
var validator = _container.Resolve<IValidator<RadarrConfiguration>>();
var result = validator.Validate(config);
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
}

@ -14,105 +14,104 @@ using TrashLib.Sonarr.Api;
using TrashLib.Sonarr.Api.Objects;
using TrashLib.Startup;
namespace TrashLib.Tests.Sonarr.Api
namespace TrashLib.Tests.Sonarr.Api;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrReleaseProfileCompatibilityHandlerTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrReleaseProfileCompatibilityHandlerTest
private class TestContext : IDisposable
{
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()
};
Mapper = AutoMapperConfig.Setup();
}
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
public IMapper Mapper { get; }
Mapper = AutoMapperConfig.Setup();
}
public void Dispose()
{
}
public IMapper Mapper { get; }
public string SerializeJson<T>(T obj)
{
return JsonConvert.SerializeObject(obj, _jsonSettings);
}
public void Dispose()
{
}
[Test]
public void Receive_v1_to_v2()
public string SerializeJson<T>(T obj)
{
using var ctx = new TestContext();
return JsonConvert.SerializeObject(obj, _jsonSettings);
}
}
var compat = Substitute.For<ISonarrCompatibility>();
var dataV1 = new SonarrReleaseProfileV1 {Ignored = "one,two,three"};
var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper);
[Test]
public void Receive_v1_to_v2()
{
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
{
Ignored = new List<string> {"one", "two", "three"}
});
}
var result = sut.CompatibleReleaseProfileForReceiving(JObject.Parse(ctx.SerializeJson(dataV1)));
[Test]
public void Receive_v2_to_v2()
result.Should().BeEquivalentTo(new SonarrReleaseProfile
{
using var ctx = new TestContext();
Ignored = new List<string> {"one", "two", "three"}
});
}
var compat = Substitute.For<ISonarrCompatibility>();
var dataV2 = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper);
[Test]
public void Receive_v2_to_v2()
{
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]
public async Task Send_v2_to_v1()
[Test]
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>();
compat.Capabilities.Returns(new[]
{
new SonarrCapabilities {ArraysNeededForReleaseProfileRequiredAndIgnored = false}
}.ToObservable());
var data = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper);
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(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]
public async Task Send_v2_to_v2()
var compat = Substitute.For<ISonarrCompatibility>();
compat.Capabilities.Returns(new[]
{
using var ctx = new TestContext();
new SonarrCapabilities {ArraysNeededForReleaseProfileRequiredAndIgnored = true}
}.ToObservable());
var compat = Substitute.For<ISonarrCompatibility>();
compat.Capabilities.Returns(new[]
{
new SonarrCapabilities {ArraysNeededForReleaseProfileRequiredAndIgnored = true}
}.ToObservable());
var data = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper);
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 TrashLib.Sonarr.QualityDefinition;
namespace TrashLib.Tests.Sonarr.QualityDefinition
namespace TrashLib.Tests.Sonarr.QualityDefinition;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrQualityDataTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrQualityDataTest
private static readonly object[] MaxTestValues =
{
private static readonly object[] MaxTestValues =
{
new object?[] {100m, 100m, false},
new object?[] {100m, 101m, true},
new object?[] {100m, 98m, true},
new object?[] {100m, null, true},
new object?[] {SonarrQualityData.MaxUnlimitedThreshold, null, false},
new object?[] {SonarrQualityData.MaxUnlimitedThreshold - 1, null, true},
new object?[] {SonarrQualityData.MaxUnlimitedThreshold, SonarrQualityData.MaxUnlimitedThreshold, true}
};
new object?[] {100m, 100m, false},
new object?[] {100m, 101m, true},
new object?[] {100m, 98m, true},
new object?[] {100m, null, true},
new object?[] {SonarrQualityData.MaxUnlimitedThreshold, null, false},
new object?[] {SonarrQualityData.MaxUnlimitedThreshold - 1, null, true},
new object?[] {SonarrQualityData.MaxUnlimitedThreshold, SonarrQualityData.MaxUnlimitedThreshold, true}
};
private static readonly object[] MinTestValues =
{
new object?[] {0m, 0m, false},
new object?[] {0m, -1m, true},
new object?[] {0m, 1m, true}
};
private static readonly object[] MinTestValues =
{
new object?[] {0m, 0m, false},
new object?[] {0m, -1m, true},
new object?[] {0m, 1m, true}
};
[TestCaseSource(nameof(MaxTestValues))]
public void MaxDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal? radarrValue,
bool isDifferent)
{
var data = new SonarrQualityData {Max = guideValue};
data.IsMaxDifferent(radarrValue)
.Should().Be(isDifferent);
}
[TestCaseSource(nameof(MaxTestValues))]
public void MaxDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal? radarrValue,
bool isDifferent)
{
var data = new SonarrQualityData {Max = guideValue};
data.IsMaxDifferent(radarrValue)
.Should().Be(isDifferent);
}
[TestCaseSource(nameof(MinTestValues))]
public void MinDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal radarrValue,
bool isDifferent)
{
var data = new SonarrQualityData {Min = guideValue};
data.IsMinDifferent(radarrValue)
.Should().Be(isDifferent);
}
[TestCaseSource(nameof(MinTestValues))]
public void MinDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal radarrValue,
bool isDifferent)
{
var data = new SonarrQualityData {Min = guideValue};
data.IsMinDifferent(radarrValue)
.Should().Be(isDifferent);
}
[Test]
public void AnnotatedMax_OutsideThreshold_EqualsSameValueWithUnlimited()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold;
var data = new SonarrQualityData {Max = testVal};
data.AnnotatedMax.Should().Be($"{testVal} (Unlimited)");
}
[Test]
public void AnnotatedMax_OutsideThreshold_EqualsSameValueWithUnlimited()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold;
var data = new SonarrQualityData {Max = testVal};
data.AnnotatedMax.Should().Be($"{testVal} (Unlimited)");
}
[Test]
public void AnnotatedMax_WithinThreshold_EqualsSameStringValue()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold - 1;
var data = new SonarrQualityData {Max = testVal};
data.AnnotatedMax.Should().Be($"{testVal}");
}
[Test]
public void AnnotatedMax_WithinThreshold_EqualsSameStringValue()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold - 1;
var data = new SonarrQualityData {Max = testVal};
data.AnnotatedMax.Should().Be($"{testVal}");
}
[Test]
public void AnnotatedMin_NoThreshold_EqualsSameValue()
{
const decimal testVal = 10m;
var data = new SonarrQualityData {Max = testVal};
data.AnnotatedMax.Should().Be($"{testVal}");
}
[Test]
public void AnnotatedMin_NoThreshold_EqualsSameValue()
{
const decimal testVal = 10m;
var data = new SonarrQualityData {Max = testVal};
data.AnnotatedMax.Should().Be($"{testVal}");
}
[Test]
public void Max_AboveThreshold_EqualsSameValue()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold + 1;
var data = new SonarrQualityData {Max = testVal};
data.Max.Should().Be(testVal);
}
[Test]
public void Max_AboveThreshold_EqualsSameValue()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold + 1;
var data = new SonarrQualityData {Max = testVal};
data.Max.Should().Be(testVal);
}
[Test]
public void MaxForApi_AboveThreshold_EqualsNull()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold + 1;
var data = new SonarrQualityData {Max = testVal};
data.MaxForApi.Should().Be(null);
}
[Test]
public void MaxForApi_AboveThreshold_EqualsNull()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold + 1;
var data = new SonarrQualityData {Max = testVal};
data.MaxForApi.Should().Be(null);
}
[Test]
public void MaxForApi_HighestWithinThreshold_EqualsSameValue()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold - 0.1m;
var data = new SonarrQualityData {Max = testVal};
data.MaxForApi.Should().Be(testVal).And.Be(data.Max);
}
[Test]
public void MaxForApi_HighestWithinThreshold_EqualsSameValue()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold - 0.1m;
var data = new SonarrQualityData {Max = testVal};
data.MaxForApi.Should().Be(testVal).And.Be(data.Max);
}
[Test]
public void MaxForApi_LowestWithinThreshold_EqualsSameValue()
{
var data = new SonarrQualityData {Max = 0};
data.MaxForApi.Should().Be(0);
}
[Test]
public void MaxForApi_LowestWithinThreshold_EqualsSameValue()
{
var data = new SonarrQualityData {Max = 0};
data.MaxForApi.Should().Be(0);
}
}

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

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

@ -2,130 +2,129 @@
using NUnit.Framework;
using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr.ReleaseProfile
namespace TrashLib.Tests.Sonarr.ReleaseProfile;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ScopedStateTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ScopedStateTest
[Test]
public void AccessValue_MultipleScopes_ScopeValuesReturned()
{
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]
public void AccessValue_MultipleScopes_ScopeValuesReturned()
{
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()
{
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);
}
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.ReleaseProfile;
namespace TrashLib.Tests.Sonarr
namespace TrashLib.Tests.Sonarr;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ReleaseProfileUpdaterTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ReleaseProfileUpdaterTest
private class Context
{
private class Context
{
public IReleaseProfileGuideParser Parser { get; } = Substitute.For<IReleaseProfileGuideParser>();
public ISonarrApi Api { get; } = Substitute.For<ISonarrApi>();
public ILogger Logger { get; } = Substitute.For<ILogger>();
public ISonarrCompatibility Compatibility { get; } = Substitute.For<ISonarrCompatibility>();
}
[Test]
public void ProcessReleaseProfile_InvalidReleaseProfiles_NoCrashNoCalls()
{
var context = new Context();
public IReleaseProfileGuideParser Parser { get; } = Substitute.For<IReleaseProfileGuideParser>();
public ISonarrApi Api { get; } = Substitute.For<ISonarrApi>();
public ILogger Logger { get; } = Substitute.For<ILogger>();
public ISonarrCompatibility Compatibility { get; } = Substitute.For<ISonarrCompatibility>();
}
[Test]
public void ProcessReleaseProfile_InvalidReleaseProfiles_NoCrashNoCalls()
{
var context = new Context();
var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api, context.Compatibility);
logic.Process(false, new SonarrConfiguration());
var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api, context.Compatibility);
logic.Process(false, new SonarrConfiguration());
context.Parser.DidNotReceive().GetMarkdownData(Arg.Any<ReleaseProfileType>());
}
context.Parser.DidNotReceive().GetMarkdownData(Arg.Any<ReleaseProfileType>());
}
[Test]
public void ProcessReleaseProfile_SingleProfilePreview()
{
var context = new Context();
[Test]
public void ProcessReleaseProfile_SingleProfilePreview()
{
var context = new Context();
context.Parser.GetMarkdownData(ReleaseProfileType.Anime).Returns("theMarkdown");
var config = new SonarrConfiguration
{
ReleaseProfiles = new[] {new ReleaseProfileConfig {Type = ReleaseProfileType.Anime}}
};
context.Parser.GetMarkdownData(ReleaseProfileType.Anime).Returns("theMarkdown");
var config = new SonarrConfiguration
{
ReleaseProfiles = new[] {new ReleaseProfileConfig {Type = ReleaseProfileType.Anime}}
};
var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api, context.Compatibility);
logic.Process(false, config);
var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api, context.Compatibility);
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.ReleaseProfile;
namespace TrashLib.Tests.Sonarr
namespace TrashLib.Tests.Sonarr;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrConfigurationTest
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrConfigurationTest
{
private IContainer _container = default!;
private IContainer _container = default!;
[OneTimeSetUp]
public void Setup()
{
var builder = new ContainerBuilder();
builder.RegisterModule<ConfigAutofacModule>();
builder.RegisterModule<SonarrAutofacModule>();
_container = builder.Build();
}
[OneTimeSetUp]
public void Setup()
{
var builder = new ContainerBuilder();
builder.RegisterModule<ConfigAutofacModule>();
builder.RegisterModule<SonarrAutofacModule>();
_container = builder.Build();
}
[Test]
public void Validation_fails_for_all_missing_required_properties()
{
// default construct which should yield default values (invalid) for all required properties
var config = new SonarrConfiguration();
var validator = _container.Resolve<IValidator<SonarrConfiguration>>();
[Test]
public void Validation_fails_for_all_missing_required_properties()
{
// default construct which should yield default values (invalid) for all required properties
var config = new SonarrConfiguration();
var validator = _container.Resolve<IValidator<SonarrConfiguration>>();
var result = validator.Validate(config);
var result = validator.Validate(config);
var expectedErrorMessageSubstrings = new[]
{
"Property 'base_url' is required",
"Property 'api_key' is required",
"'type' is required for 'release_profiles' elements"
};
var expectedErrorMessageSubstrings = new[]
{
"Property 'base_url' is required",
"Property 'api_key' is required",
"'type' is required for 'release_profiles' elements"
};
result.IsValid.Should().BeFalse();
result.Errors.Select(e => e.ErrorMessage).Should()
.OnlyContain(x => expectedErrorMessageSubstrings.Any(x.Contains));
}
result.IsValid.Should().BeFalse();
result.Errors.Select(e => e.ErrorMessage).Should()
.OnlyContain(x => expectedErrorMessageSubstrings.Any(x.Contains));
}
[Test]
public void Validation_succeeds_when_no_missing_required_properties()
[Test]
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",
BaseUrl = "required value",
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new() {Type = ReleaseProfileType.Anime}
}
};
new() {Type = ReleaseProfileType.Anime}
}
};
var validator = _container.Resolve<IValidator<SonarrConfiguration>>();
var result = validator.Validate(config);
var validator = _container.Resolve<IValidator<SonarrConfiguration>>();
var result = validator.Validate(config);
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
}

@ -1,13 +1,12 @@
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;
namespace TrashLib.Cache
namespace TrashLib.Cache;
[AttributeUsage(AttributeTargets.Class)]
internal sealed class CacheObjectNameAttribute : Attribute
{
[AttributeUsage(AttributeTargets.Class)]
internal sealed class CacheObjectNameAttribute : Attribute
public CacheObjectNameAttribute(string name)
{
public CacheObjectNameAttribute(string name)
{
Name = name;
}
public string Name { get; }
Name = name;
}
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 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);
private readonly IConfigurationProvider _configProvider;
private readonly IFileSystem _fileSystem;
private readonly IFNV1a _hash;
private readonly ICacheStoragePath _storagePath;
_fileSystem = fileSystem;
_storagePath = storagePath;
_configProvider = configProvider;
Log = log;
_hash = FNV1aFactory.Instance.Create(FNVConfig.GetPredefinedConfig(32));
}
public ServiceCache(IFileSystem fileSystem, ICacheStoragePath storagePath,
IConfigurationProvider configProvider,
ILogger log)
private ILogger Log { get; }
public T? Load<T>() where T : class
{
var path = PathFromAttribute<T>();
if (!_fileSystem.File.Exists(path))
{
_fileSystem = fileSystem;
_storagePath = storagePath;
_configProvider = configProvider;
Log = log;
_hash = FNV1aFactory.Instance.Create(FNVConfig.GetPredefinedConfig(32));
return null;
}
private ILogger Log { get; }
var json = _fileSystem.File.ReadAllText(path);
public T? Load<T>() where T : class
try
{
var path = PathFromAttribute<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;
return JObject.Parse(json).ToObject<T>();
}
public void Save<T>(T obj) where T : class
catch (JsonException e)
{
var path = PathFromAttribute<T>();
_fileSystem.Directory.CreateDirectory(Path.GetDirectoryName(path));
_fileSystem.File.WriteAllText(path, JsonConvert.SerializeObject(obj, new JsonSerializerSettings
{
Formatting = Formatting.Indented,
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new SnakeCaseNamingStrategy()
}
}));
Log.Error("Failed to read cache data, will proceed without cache. Reason: {Msg}", e.Message);
}
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>();
if (attribute == null)
Formatting = Formatting.Indented,
ContractResolver = new DefaultContractResolver
{
throw new ArgumentException($"{nameof(CacheObjectNameAttribute)} is missing on type {nameof(T)}");
NamingStrategy = new SnakeCaseNamingStrategy()
}
}));
}
return attribute.Name;
}
private string BuildServiceGuid()
private static string GetCacheObjectNameAttribute<T>()
{
var attribute = typeof(T).GetCustomAttribute<CacheObjectNameAttribute>();
if (attribute == null)
{
return _hash.ComputeHash(Encoding.ASCII.GetBytes(_configProvider.ActiveConfiguration.BaseUrl))
.AsHexString();
throw new ArgumentException($"{nameof(CacheObjectNameAttribute)} is missing on type {nameof(T)}");
}
private string PathFromAttribute<T>()
{
var objectName = GetCacheObjectNameAttribute<T>();
if (!AllowedObjectNameCharacters.IsMatch(objectName))
{
throw new ArgumentException($"Object name '{objectName}' has unacceptable characters");
}
return attribute.Name;
}
private string BuildServiceGuid()
{
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 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>()
.SingleInstance();
builder.RegisterType<ConfigurationProvider>()
.As<IConfigurationProvider>()
.SingleInstance();
builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
.AsClosedTypesOf(typeof(IValidator<>))
.AsImplementedInterfaces();
}
builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
.AsClosedTypesOf(typeof(IValidator<>))
.AsImplementedInterfaces();
}
}

@ -1,15 +1,14 @@
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
{
get => _activeConfiguration ?? throw new NullReferenceException("Active configuration has not been set");
set => _activeConfiguration = value;
}
public IServiceConfiguration ActiveConfiguration
{
get => _activeConfiguration ?? throw new NullReferenceException("Active configuration has not been set");
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;
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 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)
{
_config = config;
_log = log;
}
public ServerInfo(IConfigurationProvider config, ILogger log)
{
_config = config;
_log = log;
}
public IFlurlRequest BuildRequest()
{
var apiKey = _config.ActiveConfiguration.ApiKey;
var baseUrl = _config.ActiveConfiguration.BaseUrl;
public IFlurlRequest BuildRequest()
{
var apiKey = _config.ActiveConfiguration.ApiKey;
var baseUrl = _config.ActiveConfiguration.BaseUrl;
return baseUrl
.AppendPathSegment("api/v3")
.SetQueryParams(new {apikey = apiKey})
.SanitizedLogging(_log);
}
return baseUrl
.AppendPathSegment("api/v3")
.SetQueryParams(new {apikey = apiKey})
.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.Runtime.Serialization;
namespace TrashLib.ExceptionTypes
namespace TrashLib.ExceptionTypes;
[Serializable]
public class VersionException : Exception
{
[Serializable]
public class VersionException : Exception
public VersionException(string msg)
: base(msg)
{
public VersionException(string msg)
: base(msg)
{
}
}
protected VersionException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
}
protected VersionException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
}
}

@ -3,34 +3,33 @@ using Flurl;
using Flurl.Http;
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)
=> new FlurlRequest(url).SanitizedLogging(log);
public static IFlurlRequest SanitizedLogging(this Url url, ILogger log)
=> new FlurlRequest(url).SanitizedLogging(log);
public static IFlurlRequest SanitizedLogging(this string url, ILogger log)
=> new FlurlRequest(url).SanitizedLogging(log);
public static IFlurlRequest SanitizedLogging(this string url, ILogger log)
=> new FlurlRequest(url).SanitizedLogging(log);
public static IFlurlRequest SanitizedLogging(this IFlurlRequest request, ILogger log)
{
return request.ConfigureRequest(settings => FlurlLogging.SetupLogging(settings, log, SanitizeUrl));
}
public static IFlurlRequest SanitizedLogging(this IFlurlRequest request, ILogger log)
{
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.Host = "hostname";
if (url.QueryParams.Contains("apikey"))
{
url.QueryParams.AddOrReplace("apikey", "SNIP");
}
return url;
url.QueryParams.AddOrReplace("apikey", "SNIP");
}
return url;
}
}

@ -3,26 +3,25 @@ using Flurl;
using Flurl.Http.Configuration;
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 =>
{
var url = urlInterceptor(call.Request.Url.Clone());
log.Debug("HTTP Request to {Url}", url);
};
settings.BeforeCall = call =>
{
var url = urlInterceptor(call.Request.Url.Clone());
log.Debug("HTTP Request to {Url}", url);
};
settings.AfterCall = call =>
{
var statusCode = call.Response?.StatusCode.ToString() ?? "(No response)";
var url = urlInterceptor(call.Request.Url.Clone());
log.Debug("HTTP Response {Status} from {Url}", statusCode, url);
};
}
settings.AfterCall = call =>
{
var statusCode = call.Response?.StatusCode.ToString() ?? "(No response)";
var url = urlInterceptor(call.Request.Url.Clone());
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 CustomFormatNamesAndIds { get; }
string QualityProfileName { get; }
string QualityDefinitionType { get; }
}
string BaseUrl { get; }
string ApiKey { get; }
string CustomFormatNamesAndIds { 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.Radarr.QualityDefinition;
namespace TrashLib.Radarr.Config
namespace TrashLib.Radarr.Config;
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class RadarrConfiguration : ServiceConfiguration
{
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class RadarrConfiguration : ServiceConfiguration
{
public QualityDefinitionConfig? QualityDefinition { get; init; }
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)]
public class CustomFormatConfig
{
public ICollection<string> Names { get; init; } = new List<string>();
public ICollection<string> TrashIds { get; init; } = new List<string>();
public ICollection<QualityProfileConfig> QualityProfiles { get; init; } = new List<QualityProfileConfig>();
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class CustomFormatConfig
{
public ICollection<string> Names { get; init; } = new List<string>();
public ICollection<string> TrashIds { get; init; } = new List<string>();
public ICollection<QualityProfileConfig> QualityProfiles { get; init; } = new List<QualityProfileConfig>();
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class QualityProfileConfig
{
public string Name { get; init; } = "";
public int? Score { get; init; }
public bool ResetUnmatchedScores { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class QualityProfileConfig
{
public string Name { get; init; } = "";
public int? Score { get; init; }
public bool ResetUnmatchedScores { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
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.
// All of this craziness is to avoid making the enum type nullable.
public RadarrQualityDefinitionType Type { get; init; } = (RadarrQualityDefinitionType) (-1);
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
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.
// All of this craziness is to avoid making the enum type nullable.
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 JetBrains.Annotations;
namespace TrashLib.Radarr.Config
namespace TrashLib.Radarr.Config;
[UsedImplicitly]
internal class RadarrConfigurationValidator : AbstractValidator<RadarrConfiguration>
{
[UsedImplicitly]
internal class RadarrConfigurationValidator : AbstractValidator<RadarrConfiguration>
public RadarrConfigurationValidator(
IRadarrValidationMessages messages,
IValidator<QualityDefinitionConfig> qualityDefinitionConfigValidator,
IValidator<CustomFormatConfig> customFormatConfigValidator)
{
public RadarrConfigurationValidator(
IRadarrValidationMessages messages,
IValidator<QualityDefinitionConfig> qualityDefinitionConfigValidator,
IValidator<CustomFormatConfig> 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);
}
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]
internal class CustomFormatConfigValidator : AbstractValidator<CustomFormatConfig>
[UsedImplicitly]
internal class CustomFormatConfigValidator : AbstractValidator<CustomFormatConfig>
{
public CustomFormatConfigValidator(
IRadarrValidationMessages messages,
IValidator<QualityProfileConfig> qualityProfileConfigValidator)
{
public CustomFormatConfigValidator(
IRadarrValidationMessages messages,
IValidator<QualityProfileConfig> qualityProfileConfigValidator)
{
RuleFor(x => x.Names).NotEmpty().When(x => x.TrashIds.Count == 0)
.WithMessage(messages.CustomFormatNamesAndIds);
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]
internal class QualityProfileConfigValidator : AbstractValidator<QualityProfileConfig>
[UsedImplicitly]
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]
internal class QualityDefinitionConfigValidator : AbstractValidator<QualityDefinitionConfig>
[UsedImplicitly]
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 =>
"Property 'api_key' is required";
public string ApiKey =>
"Property 'api_key' is required";
public string CustomFormatNamesAndIds =>
"'custom_formats' elements must contain at least one element in either 'names' or 'trash_ids'";
public string CustomFormatNamesAndIds =>
"'custom_formats' elements must contain at least one element in either 'names' or 'trash_ids'";
public string QualityProfileName =>
"'name' is required for elements under 'quality_profiles'";
public string QualityProfileName =>
"'name' is required for elements under 'quality_profiles'";
public string QualityDefinitionType =>
"'type' is required for 'quality_definition'";
}
public string QualityDefinitionType =>
"'type' is required for 'quality_definition'";
}

@ -5,52 +5,51 @@ using Newtonsoft.Json.Linq;
using TrashLib.Config;
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)
{
_serverInfo = serverInfo;
}
public CustomFormatService(IServerInfo serverInfo)
{
_serverInfo = serverInfo;
}
public async Task<List<JObject>> GetCustomFormats()
{
return await BuildRequest()
.AppendPathSegment("customformat")
.GetJsonAsync<List<JObject>>();
}
public async Task<List<JObject>> GetCustomFormats()
{
return await BuildRequest()
.AppendPathSegment("customformat")
.GetJsonAsync<List<JObject>>();
}
public async Task CreateCustomFormat(ProcessedCustomFormatData cf)
{
var response = await BuildRequest()
.AppendPathSegment("customformat")
.PostJsonAsync(cf.Json)
.ReceiveJson<JObject>();
if (response != null)
{
cf.SetCache(response.Value<int>("id"));
}
}
public async Task CreateCustomFormat(ProcessedCustomFormatData cf)
{
var response = await BuildRequest()
.AppendPathSegment("customformat")
.PostJsonAsync(cf.Json)
.ReceiveJson<JObject>();
public async Task UpdateCustomFormat(ProcessedCustomFormatData cf)
if (response != null)
{
await BuildRequest()
.AppendPathSegment($"customformat/{cf.GetCustomFormatId()}")
.PutJsonAsync(cf.Json)
.ReceiveJson<JObject>();
cf.SetCache(response.Value<int>("id"));
}
}
public async Task DeleteCustomFormat(int customFormatId)
{
await BuildRequest()
.AppendPathSegment($"customformat/{customFormatId}")
.DeleteAsync();
}
public async Task UpdateCustomFormat(ProcessedCustomFormatData cf)
{
await BuildRequest()
.AppendPathSegment($"customformat/{cf.GetCustomFormatId()}")
.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 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 UpdateCustomFormat(ProcessedCustomFormatData cf);
Task DeleteCustomFormat(int customFormatId);
}
Task<List<JObject>> GetCustomFormats();
Task CreateCustomFormat(ProcessedCustomFormatData cf);
Task UpdateCustomFormat(ProcessedCustomFormatData cf);
Task DeleteCustomFormat(int customFormatId);
}

@ -2,11 +2,10 @@
using System.Threading.Tasks;
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 TrashLib.Config;
namespace TrashLib.Radarr.CustomFormat.Api
{
internal class QualityProfileService : IQualityProfileService
{
private readonly IServerInfo _serverInfo;
namespace TrashLib.Radarr.CustomFormat.Api;
public QualityProfileService(IServerInfo serverInfo)
{
_serverInfo = serverInfo;
}
internal class QualityProfileService : IQualityProfileService
{
private readonly IServerInfo _serverInfo;
public async Task<List<JObject>> GetQualityProfiles()
{
return await BuildRequest()
.AppendPathSegment("qualityprofile")
.GetJsonAsync<List<JObject>>();
}
public QualityProfileService(IServerInfo serverInfo)
{
_serverInfo = serverInfo;
}
public async Task<JObject> UpdateQualityProfile(JObject profileJson, int id)
{
return await BuildRequest()
.AppendPathSegment($"qualityprofile/{id}")
.PutJsonAsync(profileJson)
.ReceiveJson<JObject>();
}
public async Task<List<JObject>> GetQualityProfiles()
{
return await BuildRequest()
.AppendPathSegment("qualityprofile")
.GetJsonAsync<List<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,
NoChange,
Delete
}
Create,
Update,
NoChange,
Delete
}

@ -6,63 +6,62 @@ using TrashLib.Cache;
using TrashLib.Radarr.CustomFormat.Models;
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)
{
Log = log;
_cache = cache;
}
public CachePersister(ILogger log, IServiceCache cache)
{
Log = log;
_cache = cache;
}
private ILogger Log { get; }
public CustomFormatCache? CfCache { get; private set; }
private ILogger Log { get; }
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>();
// ReSharper disable once ConvertIfStatementToConditionalTernaryExpression
if (CfCache != null)
{
Log.Debug("Loaded Cache");
Log.Debug("Loaded Cache");
// If the version is higher OR lower, we invalidate the cache. It means there's an
// incompatibility that we do not support.
if (CfCache.Version != CustomFormatCache.LatestVersion)
{
Log.Information("Cache version mismatch ({OldVersion} vs {LatestVersion}); ignoring cache data",
CfCache.Version, CustomFormatCache.LatestVersion);
CfCache = null;
}
}
else
// If the version is higher OR lower, we invalidate the cache. It means there's an
// incompatibility that we do not support.
if (CfCache.Version != CustomFormatCache.LatestVersion)
{
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;
}
}
public void Save()
else
{
if (CfCache == null)
{
Log.Debug("Not saving cache because it is null");
return;
}
Log.Debug("Saving Cache");
_cache.Save(CfCache);
Log.Debug("Custom format cache does not exist; proceeding without it");
}
}
public void Update(IEnumerable<ProcessedCustomFormatData> customFormats)
public void Save()
{
if (CfCache == null)
{
Log.Debug("Updating cache");
CfCache = new CustomFormatCache();
CfCache!.TrashIdMappings.AddRange(customFormats
.Where(cf => cf.CacheEntry != null)
.Select(cf => cf.CacheEntry!));
Log.Debug("Not saving cache because it is null");
return;
}
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.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;
private readonly IGuideProcessor _guideProcessor;
private readonly IPersistenceProcessor _persistenceProcessor;
public CustomFormatUpdater(
ILogger log,
ICachePersister cache,
IGuideProcessor guideProcessor,
IPersistenceProcessor persistenceProcessor)
{
Log = log;
_cache = cache;
_guideProcessor = guideProcessor;
_persistenceProcessor = persistenceProcessor;
}
private ILogger Log { get; }
Log = log;
_cache = cache;
_guideProcessor = guideProcessor;
_persistenceProcessor = persistenceProcessor;
}
public async Task Process(bool isPreview, RadarrConfiguration config)
{
_cache.Load();
private ILogger Log { get; }
await _guideProcessor.BuildGuideDataAsync(config.CustomFormats.AsReadOnly(), _cache.CfCache);
public async Task Process(bool isPreview, RadarrConfiguration config)
{
_cache.Load();
if (!ValidateGuideDataAndCheckShouldProceed(config))
{
return;
}
await _guideProcessor.BuildGuideDataAsync(config.CustomFormats.AsReadOnly(), _cache.CfCache);
if (isPreview)
{
PreviewCustomFormats();
}
else
{
await _persistenceProcessor.PersistCustomFormats(_guideProcessor.ProcessedCustomFormats,
_guideProcessor.DeletedCustomFormatsInCache, _guideProcessor.ProfileScores);
if (!ValidateGuideDataAndCheckShouldProceed(config))
{
return;
}
PrintApiStatistics(_persistenceProcessor.Transactions);
PrintQualityProfileUpdates();
if (isPreview)
{
PreviewCustomFormats();
}
else
{
await _persistenceProcessor.PersistCustomFormats(_guideProcessor.ProcessedCustomFormats,
_guideProcessor.DeletedCustomFormatsInCache, _guideProcessor.ProfileScores);
// Cache all the custom formats (using ID from API response).
_cache.Update(_guideProcessor.ProcessedCustomFormats);
_cache.Save();
}
PrintApiStatistics(_persistenceProcessor.Transactions);
PrintQualityProfileUpdates();
_persistenceProcessor.Reset();
_guideProcessor.Reset();
// Cache all the custom formats (using ID from API response).
_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)
{
Log.Debug(" - {Format}: {Score} ({Reason})", customFormatName, score, reason);
}
foreach (var (customFormatName, score, reason) in scores)
{
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.Warning("The following quality profile names are not valid and should either be " +
"removed or renamed in your YAML config");
Log.Warning("{QualityProfileNames}", _persistenceProcessor.InvalidProfileNames);
}
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!");
}
private void PrintApiStatistics(CustomFormatTransactionData transactions)
if (_persistenceProcessor.InvalidProfileNames.Count > 0)
{
var created = transactions.NewCustomFormats;
if (created.Count > 0)
{
Log.Information("Created {Count} New Custom Formats: {CustomFormats}", created.Count,
created.Select(r => r.Name));
}
Log.Warning("The following quality profile names are not valid and should either be " +
"removed or renamed in your YAML config");
Log.Warning("{QualityProfileNames}", _persistenceProcessor.InvalidProfileNames);
}
}
var updated = transactions.UpdatedCustomFormats;
if (updated.Count > 0)
{
Log.Information("Updated {Count} Existing Custom Formats: {CustomFormats}", updated.Count,
updated.Select(r => r.Name));
}
private void PrintApiStatistics(CustomFormatTransactionData transactions)
{
var created = transactions.NewCustomFormats;
if (created.Count > 0)
{
Log.Information("Created {Count} New Custom Formats: {CustomFormats}", created.Count,
created.Select(r => r.Name));
}
var skipped = transactions.UnchangedCustomFormats;
if (skipped.Count > 0)
{
Log.Debug("Skipped {Count} Custom Formats that did not change: {CustomFormats}", skipped.Count,
skipped.Select(r => r.Name));
}
var updated = transactions.UpdatedCustomFormats;
if (updated.Count > 0)
{
Log.Information("Updated {Count} Existing Custom Formats: {CustomFormats}", updated.Count,
updated.Select(r => r.Name));
}
var deleted = transactions.DeletedCustomFormatIds;
if (deleted.Count > 0)
{
Log.Information("Deleted {Count} Custom Formats: {CustomFormats}", deleted.Count,
deleted.Select(r => r.CustomFormatName));
}
var skipped = transactions.UnchangedCustomFormats;
if (skipped.Count > 0)
{
Log.Debug("Skipped {Count} Custom Formats that did not change: {CustomFormats}", skipped.Count,
skipped.Select(r => r.Name));
}
var totalCount = created.Count + updated.Count;
if (totalCount > 0)
{
Log.Information("Total of {Count} custom formats synced to Radarr", totalCount);
}
else
{
Log.Information("All custom formats are already up to date!");
}
var deleted = transactions.DeletedCustomFormatIds;
if (deleted.Count > 0)
{
Log.Information("Deleted {Count} Custom Formats: {CustomFormats}", deleted.Count,
deleted.Select(r => r.CustomFormatName));
}
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)
{
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");
private bool ValidateGuideDataAndCheckShouldProceed(RadarrConfiguration config)
{
Console.WriteLine("");
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,
dupes.Count);
foreach (var cf in dupes)
{
Log.Warning(" - {TrashId}", cf.TrashId);
}
Log.Warning(" - {TrashId}", cf.TrashId);
}
Console.WriteLine("");
}
if (_guideProcessor.CustomFormatsNotInGuide.Count > 0)
{
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("");
}
Console.WriteLine("");
}
var cfsWithoutQualityProfiles = _guideProcessor.ConfigData
.Where(d => d.QualityProfiles.Count == 0)
.SelectMany(d => d.CustomFormats.Select(cf => cf.Name))
.ToList();
if (_guideProcessor.CustomFormatsNotInGuide.Count > 0)
{
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);
if (cfsWithoutQualityProfiles.Count > 0)
{
Log.Debug("These custom formats will be uploaded but are not associated to a quality profile in the " +
"config file: {UnassociatedCfs}", cfsWithoutQualityProfiles);
Console.WriteLine("");
}
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 (_guideProcessor.ConfigData.Count == 0)
{
Log.Error("Guide processing yielded no custom formats for configured instance host {BaseUrl}",
config.BaseUrl);
return false;
}
if (cfsWithoutQualityProfiles.Count > 0)
{
Log.Debug("These custom formats will be uploaded but are not associated to a quality profile in the " +
"config file: {UnassociatedCfs}", cfsWithoutQualityProfiles);
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("{CfList}", tuple);
}
Console.WriteLine("");
}
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 " +
"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("");
Log.Warning("{CfList}", tuple);
}
return true;
Console.WriteLine("");
}
private void PreviewCustomFormats()
if (_guideProcessor.CustomFormatsWithOutdatedNames.Count > 0)
{
Console.WriteLine("");
Console.WriteLine("=========================================================");
Console.WriteLine(" >>> Custom Formats From Guide <<< ");
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)));
Log.Warning("One or more custom format names in your YAML config have been renamed in the guide and " +
"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 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(" >>> 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)));
return true;
}
foreach (var (profileName, scoreMap) in _guideProcessor.ProfileScores)
{
Console.WriteLine(profileFormat, profileName, "", "");
private void PreviewCustomFormats()
{
Console.WriteLine("");
Console.WriteLine("=========================================================");
Console.WriteLine(" >>> Custom Formats From Guide <<< ");
Console.WriteLine("=========================================================");
Console.WriteLine("");
foreach (var (customFormat, score) in scoreMap.Mapping)
{
var matchingCf = _guideProcessor.ProcessedCustomFormats
.FirstOrDefault(cf => cf.TrashId.EqualsIgnoreCase(customFormat.TrashId));
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)
{
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)
{
Log.Warning("Quality Profile refers to CF not found in guide: {TrashId}",
customFormat.TrashId);
continue;
}
foreach (var (customFormat, score) in scoreMap.Mapping)
{
var matchingCf = _guideProcessor.ProcessedCustomFormats
.FirstOrDefault(cf => cf.TrashId.EqualsIgnoreCase(customFormat.TrashId));
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.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.Cache;
namespace TrashLib.Radarr.CustomFormat
namespace TrashLib.Radarr.CustomFormat;
public interface ICachePersister
{
public interface ICachePersister
{
CustomFormatCache? CfCache { get; }
void Load();
void Save();
void Update(IEnumerable<ProcessedCustomFormatData> customFormats);
}
CustomFormatCache? CfCache { get; }
void Load();
void Save();
void Update(IEnumerable<ProcessedCustomFormatData> customFormats);
}

@ -1,10 +1,9 @@
using System.Threading.Tasks;
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 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 class CustomFormatCache
{
public const int LatestVersion = 1;
public const int LatestVersion = 1;
public int Version { get; init; } = LatestVersion;
public Collection<TrashIdMapping> TrashIdMappings { get; init; } = new();
}
public int Version { get; init; } = LatestVersion;
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;
CustomFormatId = customFormatId;
}
public string CustomFormatName { get; set; }
public string TrashId { get; }
public int CustomFormatId { get; set; }
CustomFormatName = customFormatName;
TrashId = trashId;
CustomFormatId = customFormatId;
}
public string CustomFormatName { get; set; }
public string TrashId { get; }
public int CustomFormatId { get; set; }
}

@ -1,14 +1,13 @@
using System.Collections.Generic;
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; }
= new List<QualityProfileConfig>();
}
public ICollection<QualityProfileConfig> QualityProfiles { get; init; }
= new List<QualityProfileConfig>();
}

@ -3,34 +3,33 @@ using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json.Linq;
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;
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; }
Name = name;
TrashId = trashId;
Json = json;
}
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)
{
CacheEntry ??= new TrashIdMapping(TrashId, Name);
CacheEntry.CustomFormatId = customFormatId;
}
public string CacheAwareName => CacheEntry?.CustomFormatName ?? Name;
[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");
public void SetCache(int customFormatId)
{
CacheEntry ??= new TrashIdMapping(TrashId, Name);
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;
namespace TrashLib.Radarr.CustomFormat.Models
{
public record FormatMappingEntry(ProcessedCustomFormatData CustomFormat, int Score);
namespace TrashLib.Radarr.CustomFormat.Models;
public class QualityProfileCustomFormatScoreMapping
{
public QualityProfileCustomFormatScoreMapping(bool resetUnmatchedScores)
{
ResetUnmatchedScores = resetUnmatchedScores;
}
public record FormatMappingEntry(ProcessedCustomFormatData CustomFormat, int Score);
public bool ResetUnmatchedScores { get; }
public ICollection<FormatMappingEntry> Mapping { get; init; } = new List<FormatMappingEntry>();
public class QualityProfileCustomFormatScoreMapping
{
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
{
public enum FormatScoreUpdateReason
{
Updated,
Reset
}
namespace TrashLib.Radarr.CustomFormat.Models;
public record UpdatedFormatScore(
string CustomFormatName,
int Score,
FormatScoreUpdateReason Reason);
public enum FormatScoreUpdateReason
{
Updated,
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