From ebec6e9916fff31f997a9063606afd2f187c3cd1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 10 Jul 2026 18:50:25 -0700 Subject: [PATCH] Add codegen and logging test coverage with Codecov config (#260) Test and configuration changes only, no functional/library behavior changes. - codecov.yml scoped to the LanguageTags library, generated code ignored, kept informational until a default-branch baseline is established. - SaveCodeAsync tests parse emitted C# with Roslyn; LanguageSchema.GetCodeGenString and LogExtensions covered. - LogExtensions tests assert the exception is logged at Error via a capturing logger. - Dropped redundant test .editorconfig overrides (IDE0058/IDE0052/CS1591); discards satisfy IDE0058. - Microsoft.CodeAnalysis.CSharp added as a test-only dependency. Hand-written coverage ~76.6% -> ~91.6% (310 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- Directory.Packages.props | 1 + LanguageTagsTests/.editorconfig | 11 +--- LanguageTagsTests/Iso6392Tests.cs | 34 +++++++++++++ LanguageTagsTests/Iso6393Tests.cs | 34 +++++++++++++ LanguageTagsTests/LanguageSchemaTests.cs | 40 +++++++++++++++ LanguageTagsTests/LanguageTagsTests.csproj | 1 + LanguageTagsTests/LogExtensionsTests.cs | 59 ++++++++++++++++++++++ LanguageTagsTests/Rfc5646Tests.cs | 34 +++++++++++++ LanguageTagsTests/UnM49Tests.cs | 32 ++++++++++++ codecov.yml | 27 ++++++++++ 10 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 LanguageTagsTests/LanguageSchemaTests.cs create mode 100644 LanguageTagsTests/LogExtensionsTests.cs create mode 100644 codecov.yml diff --git a/Directory.Packages.props b/Directory.Packages.props index 96c740d..fcf0c72 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,6 +2,7 @@ + diff --git a/LanguageTagsTests/.editorconfig b/LanguageTagsTests/.editorconfig index 1b0e933..3280d65 100644 --- a/LanguageTagsTests/.editorconfig +++ b/LanguageTagsTests/.editorconfig @@ -6,14 +6,5 @@ root = false # Allow underscores in test method names dotnet_diagnostic.CA1707.severity = none -# Ignore unused private members -dotnet_diagnostic.IDE0052.severity = none - -# Ignore expression value is never used -dotnet_diagnostic.IDE0058.severity = none - -# Ignore missing XML docs for public test APIs -dotnet_diagnostic.CS1591.severity = none - -# Ignore making public types internal +# Fixture and collection-definition types must be public for xUnit discovery dotnet_diagnostic.CA1515.severity = none diff --git a/LanguageTagsTests/Iso6392Tests.cs b/LanguageTagsTests/Iso6392Tests.cs index c311251..8970868 100644 --- a/LanguageTagsTests/Iso6392Tests.cs +++ b/LanguageTagsTests/Iso6392Tests.cs @@ -1,3 +1,6 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + namespace ptr727.LanguageTags.Tests; public sealed class Iso6392Tests : SingleInstanceFixture @@ -126,4 +129,35 @@ public void Find_Empty_ReturnsNull() Iso6392Record? record = iso6392.Find(string.Empty, false); _ = record.Should().BeNull(); } + + [Fact] + public async Task SaveCodeAsync_GeneratesCode() + { + Iso6392Data fromData = await Iso6392Data.FromDataAsync( + GetDataFilePath(Iso6392Data.DataFileName) + ); + _ = fromData.RecordList.Length.Should().BeGreaterThan(0); + + string tempFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.cs"); + try + { + await fromData.SaveCodeAsync(tempFile); + string code = await File.ReadAllTextAsync(tempFile); + + // Emitted code must parse as valid C# (catches literal/escaping regressions) + SyntaxTree tree = CSharpSyntaxTree.ParseText(code); + IEnumerable syntaxErrors = tree.GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(diagnostic => diagnostic.ToString()); + _ = syntaxErrors.Should().BeEmpty(); + _ = code.Should().Contain("public static Iso6392Data Create() =>"); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } } diff --git a/LanguageTagsTests/Iso6393Tests.cs b/LanguageTagsTests/Iso6393Tests.cs index bae6d67..1eff0d5 100644 --- a/LanguageTagsTests/Iso6393Tests.cs +++ b/LanguageTagsTests/Iso6393Tests.cs @@ -1,3 +1,6 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + namespace ptr727.LanguageTags.Tests; public sealed class Iso6393Tests : SingleInstanceFixture @@ -126,4 +129,35 @@ public void Find_Empty_ReturnsNull() Iso6393Record? record = iso6393.Find(string.Empty, false); _ = record.Should().BeNull(); } + + [Fact] + public async Task SaveCodeAsync_GeneratesCode() + { + Iso6393Data fromData = await Iso6393Data.FromDataAsync( + GetDataFilePath(Iso6393Data.DataFileName) + ); + _ = fromData.RecordList.Length.Should().BeGreaterThan(0); + + string tempFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.cs"); + try + { + await fromData.SaveCodeAsync(tempFile); + string code = await File.ReadAllTextAsync(tempFile); + + // Emitted code must parse as valid C# (catches literal/escaping regressions) + SyntaxTree tree = CSharpSyntaxTree.ParseText(code); + IEnumerable syntaxErrors = tree.GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(diagnostic => diagnostic.ToString()); + _ = syntaxErrors.Should().BeEmpty(); + _ = code.Should().Contain("public static Iso6393Data Create() =>"); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } } diff --git a/LanguageTagsTests/LanguageSchemaTests.cs b/LanguageTagsTests/LanguageSchemaTests.cs new file mode 100644 index 0000000..8952282 --- /dev/null +++ b/LanguageTagsTests/LanguageSchemaTests.cs @@ -0,0 +1,40 @@ +namespace ptr727.LanguageTags.Tests; + +public sealed class LanguageSchemaTests +{ + [Theory] + [InlineData(null, "null")] + [InlineData("", "null")] + [InlineData("value", "\"value\"")] + public void GetCodeGenString_String(string? input, string expected) => + _ = LanguageSchema.GetCodeGenString(input).Should().Be(expected); + + [Fact] + public void GetCodeGenString_DateOnly_Null() => + _ = LanguageSchema.GetCodeGenString((DateOnly?)null).Should().Be("null"); + + [Fact] + public void GetCodeGenString_DateOnly_Value() => + _ = LanguageSchema + .GetCodeGenString(new DateOnly(2024, 3, 7)) + .Should() + .Be("new DateOnly(2024, 3, 7)"); + + [Fact] + public void GetCodeGenString_RecordType() => + _ = LanguageSchema + .GetCodeGenString(Rfc5646Record.RecordType.Language) + .Should() + .Be("Rfc5646Record.RecordType.Language"); + + [Fact] + public void GetCodeGenString_RecordScope() => + _ = LanguageSchema + .GetCodeGenString(Rfc5646Record.RecordScope.MacroLanguage) + .Should() + .Be("Rfc5646Record.RecordScope.MacroLanguage"); + + [Fact] + public void GetCodeGenString_List_EscapesQuotes() => + _ = LanguageSchema.GetCodeGenString(["a", "b\"c"]).Should().Be("[@\"a\", @\"b\"\"c\"]"); +} diff --git a/LanguageTagsTests/LanguageTagsTests.csproj b/LanguageTagsTests/LanguageTagsTests.csproj index 2e9dfc3..544a84d 100644 --- a/LanguageTagsTests/LanguageTagsTests.csproj +++ b/LanguageTagsTests/LanguageTagsTests.csproj @@ -5,6 +5,7 @@ + all diff --git a/LanguageTagsTests/LogExtensionsTests.cs b/LanguageTagsTests/LogExtensionsTests.cs new file mode 100644 index 0000000..2dafe02 --- /dev/null +++ b/LanguageTagsTests/LogExtensionsTests.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Logging; + +namespace ptr727.LanguageTags.Tests; + +public sealed class LogExtensionsTests +{ + [Fact] + public void LogAndPropagate_LogsExceptionAtErrorAndReturnsFalse() + { + CapturingLogger logger = new(); + InvalidOperationException exception = new("test"); + + bool result = logger.LogAndPropagate(exception); + + _ = result.Should().BeFalse(); + _ = logger.Entries.Should().ContainSingle(); + _ = logger.Entries[0].Level.Should().Be(LogLevel.Error); + _ = logger.Entries[0].Exception.Should().BeSameAs(exception); + } + + [Fact] + public void LogAndHandle_LogsExceptionAtErrorAndReturnsTrue() + { + CapturingLogger logger = new(); + InvalidOperationException exception = new("test"); + + bool result = logger.LogAndHandle(exception); + + _ = result.Should().BeTrue(); + _ = logger.Entries.Should().ContainSingle(); + _ = logger.Entries[0].Level.Should().Be(LogLevel.Error); + _ = logger.Entries[0].Exception.Should().BeSameAs(exception); + } + + private sealed class CapturingLogger : ILogger + { + public List<(LogLevel Level, Exception? Exception)> Entries { get; } = []; + + 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 + ) => Entries.Add((logLevel, exception)); + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() { } + } + } +} diff --git a/LanguageTagsTests/Rfc5646Tests.cs b/LanguageTagsTests/Rfc5646Tests.cs index 987b68f..a33c9fb 100644 --- a/LanguageTagsTests/Rfc5646Tests.cs +++ b/LanguageTagsTests/Rfc5646Tests.cs @@ -1,3 +1,6 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + namespace ptr727.LanguageTags.Tests; public class Rfc5646Tests : SingleInstanceFixture @@ -142,4 +145,35 @@ public void FileDate_IsSet() _ = rfc5646.FileDate.Should().NotBeNull(); _ = rfc5646.FileDate.Should().HaveValue(); } + + [Fact] + public async Task SaveCodeAsync_GeneratesCode() + { + Rfc5646Data fromData = await Rfc5646Data.FromDataAsync( + GetDataFilePath(Rfc5646Data.DataFileName) + ); + _ = fromData.RecordList.Length.Should().BeGreaterThan(0); + + string tempFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.cs"); + try + { + await fromData.SaveCodeAsync(tempFile); + string code = await File.ReadAllTextAsync(tempFile); + + // Emitted code must parse as valid C# (catches literal/escaping regressions) + SyntaxTree tree = CSharpSyntaxTree.ParseText(code); + IEnumerable syntaxErrors = tree.GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(diagnostic => diagnostic.ToString()); + _ = syntaxErrors.Should().BeEmpty(); + _ = code.Should().Contain("public static Rfc5646Data Create() =>"); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } } diff --git a/LanguageTagsTests/UnM49Tests.cs b/LanguageTagsTests/UnM49Tests.cs index 63c4929..df477b8 100644 --- a/LanguageTagsTests/UnM49Tests.cs +++ b/LanguageTagsTests/UnM49Tests.cs @@ -1,3 +1,6 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + namespace ptr727.LanguageTags.Tests; public sealed class UnM49Tests : SingleInstanceFixture @@ -128,4 +131,33 @@ public void GetAncestors_Unknown_ReturnsEmpty() UnM49Data unM49 = UnM49Data.Create(); _ = unM49.GetAncestors("ZZ").Should().BeEmpty(); } + + [Fact] + public async Task SaveCodeAsync_GeneratesCode() + { + UnM49Data fromData = await UnM49Data.FromDataAsync(GetDataFilePath(UnM49Data.DataFileName)); + _ = fromData.RecordList.Length.Should().BeGreaterThan(0); + + string tempFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.cs"); + try + { + await fromData.SaveCodeAsync(tempFile); + string code = await File.ReadAllTextAsync(tempFile); + + // Emitted code must parse as valid C# (catches literal/escaping regressions) + SyntaxTree tree = CSharpSyntaxTree.ParseText(code); + IEnumerable syntaxErrors = tree.GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(diagnostic => diagnostic.ToString()); + _ = syntaxErrors.Should().BeEmpty(); + _ = code.Should().Contain("public static UnM49Data Create() =>"); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } } diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..1a501b8 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,27 @@ +# Codecov for the LanguageTags NuGet library only. Generated code (embedded *DataGen.cs tables and .g.cs +# source-generator output) is ignored so coverage reflects hand-written code, not generated lines. +# Kept informational until a default-branch upload sets a baseline under these ignores; then flip to blocking. + +coverage: + status: + project: + default: + target: auto # compare each commit to its base + threshold: 1% # allowed drop before failing + informational: true # report-only; set false to block + patch: + default: + target: auto + threshold: 1% + informational: true + +comment: + layout: "condensed_header, diff, files, components" + require_changes: false + +ignore: + - "LanguageTagsCreate" + - "LanguageTagsTests" + - "**/*Gen.cs" + - "**/*.g.cs" + - ".artifacts/**"