diff --git a/LanguageTags/Iso6392Data.cs b/LanguageTags/Iso6392Data.cs index 2cc80b1..efa0d11 100644 --- a/LanguageTags/Iso6392Data.cs +++ b/LanguageTags/Iso6392Data.cs @@ -107,7 +107,11 @@ private static async Task LoadDataAsync(string fileName, ILogger lo /// Loads ISO 639-2 data from a JSON file asynchronously. /// /// The path to the JSON file. - /// The loaded or null if deserialization fails. + /// + /// The loaded , or null when deserialization yields no data. + /// + /// Thrown when the file cannot be read. + /// Thrown when the JSON is invalid. public static Task LoadJsonAsync(string fileName) => LoadJsonAsync(fileName, LogOptions.CreateLogger()); @@ -116,7 +120,11 @@ private static async Task LoadDataAsync(string fileName, ILogger lo /// /// The path to the JSON file. /// The options used to configure logging. - /// The loaded or null if deserialization fails. + /// + /// The loaded , or null when deserialization yields no data. + /// + /// Thrown when the file cannot be read. + /// Thrown when the JSON is invalid. public static Task LoadJsonAsync(string fileName, Options? options) => LoadJsonAsync(fileName, LogOptions.CreateLogger(options)); diff --git a/LanguageTags/Iso6393Data.cs b/LanguageTags/Iso6393Data.cs index 0ef362b..b204a1b 100644 --- a/LanguageTags/Iso6393Data.cs +++ b/LanguageTags/Iso6393Data.cs @@ -133,7 +133,11 @@ private static async Task LoadDataAsync(string fileName, ILogger lo /// Loads ISO 639-3 data from a JSON file asynchronously. /// /// The path to the JSON file. - /// The loaded or null if deserialization fails. + /// + /// The loaded , or null when deserialization yields no data. + /// + /// Thrown when the file cannot be read. + /// Thrown when the JSON is invalid. public static Task LoadJsonAsync(string fileName) => LoadJsonAsync(fileName, LogOptions.CreateLogger()); @@ -142,7 +146,11 @@ private static async Task LoadDataAsync(string fileName, ILogger lo /// /// The path to the JSON file. /// The options used to configure logging. - /// The loaded or null if deserialization fails. + /// + /// The loaded , or null when deserialization yields no data. + /// + /// Thrown when the file cannot be read. + /// Thrown when the JSON is invalid. public static Task LoadJsonAsync(string fileName, Options? options) => LoadJsonAsync(fileName, LogOptions.CreateLogger(options)); diff --git a/LanguageTags/Rfc5646Data.cs b/LanguageTags/Rfc5646Data.cs index 3027a53..95ee1ba 100644 --- a/LanguageTags/Rfc5646Data.cs +++ b/LanguageTags/Rfc5646Data.cs @@ -89,7 +89,11 @@ private static async Task LoadDataAsync(string fileName, ILogger lo /// Loads RFC 5646 data from a JSON file asynchronously. /// /// The path to the JSON file. - /// The loaded or null if deserialization fails. + /// + /// The loaded , or null when deserialization yields no data. + /// + /// Thrown when the file cannot be read. + /// Thrown when the JSON is invalid. public static Task LoadJsonAsync(string fileName) => LoadJsonAsync(fileName, LogOptions.CreateLogger()); @@ -98,7 +102,11 @@ private static async Task LoadDataAsync(string fileName, ILogger lo /// /// The path to the JSON file. /// The options used to configure logging. - /// The loaded or null if deserialization fails. + /// + /// The loaded , or null when deserialization yields no data. + /// + /// Thrown when the file cannot be read. + /// Thrown when the JSON is invalid. public static Task LoadJsonAsync(string fileName, Options? options) => LoadJsonAsync(fileName, LogOptions.CreateLogger(options)); diff --git a/LanguageTagsTests/Fixture.cs b/LanguageTagsTests/Fixture.cs index 9791dbe..5e35a36 100644 --- a/LanguageTagsTests/Fixture.cs +++ b/LanguageTagsTests/Fixture.cs @@ -1,5 +1,13 @@ namespace ptr727.LanguageTags.Tests; +[CollectionDefinition("DisableParallelDefinition", DisableParallelization = true)] +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Maintainability", + "CA1515:Consider making public types internal", + Justification = "https://xunit.net/docs/running-tests-in-parallel" +)] +public sealed class DisableParallelDefinition { } + internal static class Fixture // : IDisposable { // public void Dispose() => GC.SuppressFinalize(this); diff --git a/LanguageTagsTests/LanguageTagBuilderTests.cs b/LanguageTagsTests/LanguageTagBuilderTests.cs index da91798..1455ff8 100644 --- a/LanguageTagsTests/LanguageTagBuilderTests.cs +++ b/LanguageTagsTests/LanguageTagBuilderTests.cs @@ -91,6 +91,25 @@ public void Normalize_Pass() _ = languageTag.ToString().Should().Be("en-a-aaa-bbb-b-ccc-x-a-ccc"); } + [Fact] + public void Normalize_WithOptions_Pass() + { + Options options = new(); + + // en-Latn-GB-boont-r-extended-sequence-x-private + LanguageTag? languageTag = new LanguageTagBuilder() + .Language("en") + .Script("latn") + .Region("gb") + .VariantAdd("boont") + .ExtensionAdd('r', ["extended", "sequence"]) + .PrivateUseAdd("private") + .Normalize(options); + _ = languageTag.Should().NotBeNull(); + _ = languageTag!.Validate().Should().BeTrue(); + _ = languageTag.ToString().Should().Be("en-GB-boont-r-extended-sequence-x-private"); + } + [Fact] public void Build_Fail() { diff --git a/LanguageTagsTests/LanguageTagTests.cs b/LanguageTagsTests/LanguageTagTests.cs index 4d59d25..bc30cd2 100644 --- a/LanguageTagsTests/LanguageTagTests.cs +++ b/LanguageTagsTests/LanguageTagTests.cs @@ -17,6 +17,20 @@ public void Parse_Static_Pass(string tag) _ = languageTag.ToString().Should().Be(tag); } + [Theory] + [InlineData("en-US")] + [InlineData("zh-Hans-CN")] + [InlineData("en-latn-gb-boont-r-extended-sequence-x-private")] + [InlineData("x-all-private")] + public void Parse_WithOptions_Pass(string tag) + { + Options options = new(); + LanguageTag? languageTag = LanguageTag.Parse(tag, options); + _ = languageTag.Should().NotBeNull(); + _ = languageTag!.Validate().Should().BeTrue(); + _ = languageTag.ToString().Should().Be(tag); + } + [Theory] [InlineData("")] // Empty string [InlineData("i")] // Too short @@ -31,6 +45,21 @@ public void Parse_Static_ReturnsNull(string tag) _ = languageTag.Should().BeNull(); } + [Theory] + [InlineData("")] // Empty string + [InlineData("i")] // Too short + [InlineData("abcdefghi")] // Too long + [InlineData("en--gb")] // Empty tag + [InlineData("en-€-extension")] // Non-ASCII + [InlineData("a-extension")] // Only start with x or grandfathered + [InlineData("en-gb-x")] // Private must have parts + public void Parse_WithOptions_ReturnsNull(string tag) + { + Options options = new(); + LanguageTag? languageTag = LanguageTag.Parse(tag, options); + _ = languageTag.Should().BeNull(); + } + [Theory] [InlineData("en-US")] [InlineData("zh-Hans-CN")] @@ -44,6 +73,20 @@ public void TryParse_Success(string tag) _ = languageTag.ToString().Should().Be(tag); } + [Theory] + [InlineData("en-US")] + [InlineData("zh-Hans-CN")] + [InlineData("en-latn-gb-boont-r-extended-sequence-x-private")] + public void TryParse_WithOptions_Success(string tag) + { + Options options = new(); + bool result = LanguageTag.TryParse(tag, out LanguageTag? languageTag, options); + _ = result.Should().BeTrue(); + _ = languageTag.Should().NotBeNull(); + _ = languageTag!.Validate().Should().BeTrue(); + _ = languageTag.ToString().Should().Be(tag); + } + [Theory] [InlineData("")] // Empty string [InlineData("i")] // Too short @@ -60,6 +103,23 @@ public void TryParse_Failure(string tag) _ = languageTag.Should().BeNull(); } + [Theory] + [InlineData("")] // Empty string + [InlineData("i")] // Too short + [InlineData("abcdefghi")] // Too long + [InlineData("en--gb")] // Empty tag + [InlineData("en-€-extension")] // Non-ASCII + [InlineData("a-extension")] // Only start with x or grandfathered + [InlineData("en-gb-x")] // Private must have parts + [InlineData("x")] // Private missing + public void TryParse_WithOptions_Failure(string tag) + { + Options options = new(); + bool result = LanguageTag.TryParse(tag, out LanguageTag? languageTag, options); + _ = result.Should().BeFalse(); + _ = languageTag.Should().BeNull(); + } + [Fact] public void CreateBuilder_Pass() { @@ -162,6 +222,28 @@ public void ParseAndNormalize_InvalidTag_ReturnsNull() _ = result.Should().BeNull(); } + [Theory] + [InlineData("en-latn-us", "en-US")] + [InlineData("zh-cmn-Hans-CN", "cmn-Hans-CN")] + public void ParseAndNormalize_WithOptions_ValidTag_ReturnsNormalized( + string tag, + string expected + ) + { + Options options = new(); + LanguageTag? result = LanguageTag.ParseAndNormalize(tag, options); + _ = result.Should().NotBeNull(); + _ = result!.ToString().Should().Be(expected); + } + + [Fact] + public void ParseAndNormalize_WithOptions_InvalidTag_ReturnsNull() + { + Options options = new(); + LanguageTag? result = LanguageTag.ParseAndNormalize("invalid-tag", options); + _ = result.Should().BeNull(); + } + [Fact] public void IsValid_Property_ValidTag_ReturnsTrue() { diff --git a/LanguageTagsTests/LogOptionsTests.cs b/LanguageTagsTests/LogOptionsTests.cs new file mode 100644 index 0000000..df5806c --- /dev/null +++ b/LanguageTagsTests/LogOptionsTests.cs @@ -0,0 +1,258 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ptr727.LanguageTags.Tests; + +[Collection("DisableParallelDefinition")] +public sealed class LogOptionsTests +{ + [Fact] + public void CreateLogger_UsesFactory_WhenFactorySet() + { + ILoggerFactory originalFactory = LogOptions.LoggerFactory; + ILogger originalLogger = LogOptions.Logger; + using TestLoggerFactory testFactory = new(); + TestLogger testLogger = new(); + + try + { + LogOptions.LoggerFactory = testFactory; + LogOptions.Logger = testLogger; + + ILogger logger = LogOptions.CreateLogger("category"); + + _ = logger.Should().BeSameAs(testFactory.Logger); + _ = testFactory.LastCategory.Should().Be("category"); + } + finally + { + LogOptions.LoggerFactory = originalFactory; + LogOptions.Logger = originalLogger; + } + } + + [Fact] + public void CreateLogger_UsesLogger_WhenFactoryDefault() + { + ILoggerFactory originalFactory = LogOptions.LoggerFactory; + ILogger originalLogger = LogOptions.Logger; + TestLogger testLogger = new(); + + try + { + LogOptions.LoggerFactory = NullLoggerFactory.Instance; + LogOptions.Logger = testLogger; + + ILogger logger = LogOptions.CreateLogger("category"); + + _ = logger.Should().BeSameAs(testLogger); + } + finally + { + LogOptions.LoggerFactory = originalFactory; + LogOptions.Logger = originalLogger; + } + } + + [Fact] + public void CreateLogger_WithOptions_UsesOptionsFactory() + { + ILoggerFactory originalFactory = LogOptions.LoggerFactory; + ILogger originalLogger = LogOptions.Logger; + using TestLoggerFactory testFactory = new(); + TestLogger testLogger = new(); + Options options = new() { LoggerFactory = testFactory, Logger = testLogger }; + + try + { + LogOptions.LoggerFactory = NullLoggerFactory.Instance; + LogOptions.Logger = NullLogger.Instance; + + ILogger logger = LogOptions.CreateLogger("category", options); + + _ = logger.Should().BeSameAs(testFactory.Logger); + _ = testFactory.LastCategory.Should().Be("category"); + } + finally + { + LogOptions.LoggerFactory = originalFactory; + LogOptions.Logger = originalLogger; + } + } + + [Fact] + public void CreateLogger_WithOptions_UsesOptionsLoggerWhenNoFactory() + { + ILoggerFactory originalFactory = LogOptions.LoggerFactory; + ILogger originalLogger = LogOptions.Logger; + TestLogger testLogger = new(); + Options options = new() { Logger = testLogger }; + + try + { + using TestLoggerFactory testFactory = new(); + LogOptions.LoggerFactory = testFactory; + LogOptions.Logger = new TestLogger(); + + ILogger logger = LogOptions.CreateLogger("category", options); + + _ = logger.Should().BeSameAs(testLogger); + } + finally + { + LogOptions.LoggerFactory = originalFactory; + LogOptions.Logger = originalLogger; + } + } + + [Fact] + public void CreateLogger_WithOptions_FallsBackToGlobal() + { + ILoggerFactory originalFactory = LogOptions.LoggerFactory; + ILogger originalLogger = LogOptions.Logger; + using TestLoggerFactory testFactory = new(); + Options options = new(); + + try + { + LogOptions.LoggerFactory = testFactory; + LogOptions.Logger = new TestLogger(); + + ILogger logger = LogOptions.CreateLogger("category", options); + + _ = logger.Should().BeSameAs(testFactory.Logger); + _ = testFactory.LastCategory.Should().Be("category"); + } + finally + { + LogOptions.LoggerFactory = originalFactory; + LogOptions.Logger = originalLogger; + } + } + + [Fact] + public void TrySetFactory_WhenUnset_ReturnsTrueAndSets() + { + ILoggerFactory originalFactory = LogOptions.LoggerFactory; + using TestLoggerFactory testFactory = new(); + + try + { + LogOptions.LoggerFactory = NullLoggerFactory.Instance; + + bool result = LogOptions.TrySetFactory(testFactory); + + _ = result.Should().BeTrue(); + _ = LogOptions.LoggerFactory.Should().BeSameAs(testFactory); + } + finally + { + LogOptions.LoggerFactory = originalFactory; + } + } + + [Fact] + public void TrySetFactory_WhenAlreadySet_ReturnsFalseAndDoesNotOverwrite() + { + ILoggerFactory originalFactory = LogOptions.LoggerFactory; + using TestLoggerFactory testFactory = new(); + using TestLoggerFactory otherFactory = new(); + + try + { + LogOptions.LoggerFactory = testFactory; + + bool result = LogOptions.TrySetFactory(otherFactory); + + _ = result.Should().BeFalse(); + _ = LogOptions.LoggerFactory.Should().BeSameAs(testFactory); + } + finally + { + LogOptions.LoggerFactory = originalFactory; + } + } + + [Fact] + public void TrySetLogger_WhenUnset_ReturnsTrueAndSets() + { + ILogger originalLogger = LogOptions.Logger; + TestLogger testLogger = new(); + + try + { + LogOptions.Logger = NullLogger.Instance; + + bool result = LogOptions.TrySetLogger(testLogger); + + _ = result.Should().BeTrue(); + _ = LogOptions.Logger.Should().BeSameAs(testLogger); + } + finally + { + LogOptions.Logger = originalLogger; + } + } + + [Fact] + public void TrySetLogger_WhenAlreadySet_ReturnsFalseAndDoesNotOverwrite() + { + ILogger originalLogger = LogOptions.Logger; + TestLogger testLogger = new(); + TestLogger otherLogger = new(); + + try + { + LogOptions.Logger = testLogger; + + bool result = LogOptions.TrySetLogger(otherLogger); + + _ = result.Should().BeFalse(); + _ = LogOptions.Logger.Should().BeSameAs(testLogger); + } + finally + { + LogOptions.Logger = originalLogger; + } + } + + private sealed class TestLoggerFactory : ILoggerFactory + { + public ILogger Logger { get; } = new TestLogger(); + + public string? LastCategory { get; private set; } + + public void AddProvider(ILoggerProvider provider) { } + + public ILogger CreateLogger(string categoryName) + { + LastCategory = categoryName; + return Logger; + } + + public void Dispose() { } + } + + private sealed class TestLogger : ILogger + { + public IDisposable BeginScope(TState state) + where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) { } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() { } + } + } +}