diff --git a/.buildvana/hooks/release/post-release.cs b/.buildvana/hooks/release/post-release.cs index 79893e73..a1e557b3 100644 --- a/.buildvana/hooks/release/post-release.cs +++ b/.buildvana/hooks/release/post-release.cs @@ -3,6 +3,7 @@ #:package Buildvana.Runtime +using System.Diagnostics; using System.IO; using System.Text.RegularExpressions; using Buildvana.Runtime; @@ -28,7 +29,9 @@ // next file sits beside the one it replaces, and is checked in, so a missing one is a broken tree: // File.Copy throws, the hook exits non-zero, and the release fails naming the path. Skipping instead // would revert the configuration file at the release after. -var nextConfigFile = Path.Combine(Path.GetDirectoryName(configFile)!, "buildvana.next.jsonc"); +var configDirectory = Path.GetDirectoryName(configFile); +Debug.Assert(configDirectory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); +var nextConfigFile = Path.Combine(configDirectory, "buildvana.next.jsonc"); File.Copy(nextConfigFile, configFile, overwrite: true); // Same expression as SelfVersionService.SchemaUrlRegex in src/Buildvana.Tool, which `bv self-update` applies to diff --git a/.claude/rules/common/csharp-style-guide.md b/.claude/rules/common/csharp-style-guide.md index 260bd8a4..f018e0ff 100644 --- a/.claude/rules/common/csharp-style-guide.md +++ b/.claude/rules/common/csharp-style-guide.md @@ -51,6 +51,40 @@ Semantic differences to keep in mind when converting: - List, property, and recursive patterns imply a null test. `propertyPath is [PropertyName, PackageId]` is `false` when `propertyPath` is null, where `propertyPath.Count == 2` would throw. That is usually an improvement, but it is a behavior change, not a pure rewrite. - Constant patterns never use the type's `==` operator. A non-null constant compares with `Equals`. `null` is a plain reference test, or a `HasValue` check for a nullable value type. With `float` and `double` the pattern is what you want: `x is double.NaN` matches NaN, while `x == double.NaN` is always `false`. Exception: when a type overloads `==` so that `x == null` and `x is null` disagree, keep the operator form. +## The null-forgiving operator + +Do not write the null-forgiving operator, `!`. The operator tells the compiler that a value is not null, and the compiler stops checking it. The fact behind the operator then lives in no type, and a reader has to rebuild it from code elsewhere. `Nullable.Extended.Analyzer` reports every operator as NX0001 to NX0004, and every warning is an error here. + +Remove the cause of the operator instead. Try these fixes in order: + +1. Let a type state the fact. Make the member non-nullable, or return a type that carries the non-null value next to the rest. A method that filters items on a nullable member returns each item with its non-null value. +2. Keep a value the code built. Do not read it back through a nullable API. +3. Write a check that flow analysis follows: a pattern such as `is { } value` or `is JsonObject obj`, or a call to a `[NotNullWhen]` method inside the condition. Flow analysis does not follow a `bool` local that records the outcome of a check. +4. When no type and no check removes the operator, state the fact with `Debug.Assert(value is not null, "")`. The message says why the value cannot be null. Store the value in a local first, when it has none. + +Do not suppress the analyzer with its justification comment, a `// !` line above the statement. The comment states the fact where no tool checks it. `Debug.Assert` checks it in every Debug build, tests included. + +### In tests + +A test checks the null with the test framework, so that a null fails the test instead of throwing: + +- `await Assert.That(value).IsNotNull()` returns the value, non-null. +- A comparison through `?.` fails on a null: `await Assert.That(slash["type"]?.GetValue()).IsEqualTo("string")`. +- A test that asserts a value reached through `?.` is null first asserts the parent non-null. Otherwise a missing parent passes the test. +- Chain `WithMessage` or `WithMessageContaining` onto `Throws()`, instead of reading `Message` from the exception it returns. +- To read another member of the exception, use `Assert.Throws(Action)` for a synchronous act, which returns the exception non-null. After an awaited `Throws()`, write `Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple.")`. + +A synchronous helper cannot await an assertion. When the null comes from the code under test, the helper throws `InvalidOperationException`. When the fact does not depend on the code under test, such as `Path.GetDirectoryName` of a file path, the helper uses `Debug.Assert`. Keep `Debug.Assert` for such facts, because a failed `Debug.Assert` ends the test process through `Environment.FailFast`. + +One case keeps the operator. When a test passes `null!` or `default!` on purpose, as the input under test, put this comment on the line above the statement: + +```csharp +// ! Null on purpose: testing null detection +static GitignorePattern? Act() => GitignorePattern.TryParse(null!); +``` + +Use this text and no other, so that a search finds every such input. + ## Partial classes When a class exceeds 500 lines of code, or has nested types, split it into partial class files. diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f72372..9a9991b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,7 @@ The patch number restarted from 1, below the 2.0.x versions already published. - `bv release` fails when the remote rejects the [push of its commits](docs/tool-commands/release.md#the-release-tag), where it used to go on as if the push had succeeded. - `bv release` reports a failed fetch or [push](docs/tool-commands/release.md#the-release-tag) as one error line naming the release remote, not as an unhandled exception. - When a `PackageVersion` item versions a package Buildvana SDK injects, the reference [is an ordinary `PackageReference`](docs/sdk-modules/standard-analyzers.md#package-references), where NuGet rejected the item with NU1009. +- `bv` no longer fails with `ArgumentException` when two [version tags](docs/tool-commands/version.md#bv-version-show) name the same commit. ### Known problems introduced by this release diff --git a/Common.props b/Common.props index 77c9dbed..26f44f2e 100644 --- a/Common.props +++ b/Common.props @@ -39,9 +39,6 @@ false false false - - - false diff --git a/docs/tool-commands/version.md b/docs/tool-commands/version.md index 7292da45..e5ade89c 100644 --- a/docs/tool-commands/version.md +++ b/docs/tool-commands/version.md @@ -41,6 +41,7 @@ Current branch: main - The latest version is the version of the first tag, walking the history from `HEAD`, whose name is a semantic version. - The latest stable version is the version of the first such tag with no prerelease part. Both lines read `(none)` when there is no such tag. +- When one commit carries several such tags, the highest version among them counts. - The public release line says whether the current branch [produces public releases](../sdk-modules/versioning.md#public-releases). - The prerelease line says whether `VERSION` marks a prerelease line. - The current branch is the short name of the branch, or `(detached HEAD)`. diff --git a/src/Buildvana.Core.Configuration/BuildvanaJsonConfigExample.cs b/src/Buildvana.Core.Configuration/BuildvanaJsonConfigExample.cs index 2ce8912a..33cf200d 100644 --- a/src/Buildvana.Core.Configuration/BuildvanaJsonConfigExample.cs +++ b/src/Buildvana.Core.Configuration/BuildvanaJsonConfigExample.cs @@ -166,6 +166,7 @@ internal static IReadOnlyList WrapDescription(string description) /// The schema document the pointer is relative to. /// The setting the reference was found at, for the error message. /// The subschema the pointer names. + /// carries no $ref keyword. /// The pointer does not name a subschema. /// /// A document-local pointer is the only kind the exporter emits, for a member type that occurs more @@ -177,7 +178,8 @@ internal static JsonObject ResolveReference(JsonObject schema, JsonObject root, ArgumentNullException.ThrowIfNull(schema); ArgumentNullException.ThrowIfNull(root); - var pointer = schema["$ref"]!.GetValue(); + var pointer = schema["$ref"]?.GetValue() + ?? throw new ArgumentException("The schema carries no \"$ref\" keyword.", nameof(schema)); JsonNode node = root; foreach (var token in pointer.Split('/').Skip(1)) { @@ -310,7 +312,12 @@ private static void WriteValue(StringBuilder text, JsonObject schema, JsonObject private static List<(string Name, JsonObject Schema)> CollectMembers(JsonObject schema) { List<(string Name, JsonObject Schema)> members = []; - foreach (var (name, node) in (JsonObject)schema["properties"]!) + if (schema["properties"] is not JsonObject properties) + { + return members; + } + + foreach (var (name, node) in properties) { if (node is JsonObject member) { diff --git a/src/Buildvana.Core.Configuration/BuildvanaJsonConfigProvider.cs b/src/Buildvana.Core.Configuration/BuildvanaJsonConfigProvider.cs index 8c0dc5b4..2c89fd0b 100644 --- a/src/Buildvana.Core.Configuration/BuildvanaJsonConfigProvider.cs +++ b/src/Buildvana.Core.Configuration/BuildvanaJsonConfigProvider.cs @@ -105,11 +105,11 @@ public static BuildvanaJsonConfig LoadFile(string? path) var node = Parse(json, path); - // Parse throws unless the document is well-formed JSON, and well-formed JSON always makes a map. - Validate(node, sourceMap!, path); + Debug.Assert(sourceMap is not null, "Parse throws unless the document is well-formed JSON, which always makes a map."); + Validate(node, sourceMap, path); // Validation guarantees a non-null object at the root, so deserialization cannot return null here. - return node!.Deserialize(BuildvanaJsonConfigContext.Default.BuildvanaJsonConfig) ?? new BuildvanaJsonConfig(); + return node.Deserialize(BuildvanaJsonConfigContext.Default.BuildvanaJsonConfig) ?? new BuildvanaJsonConfig(); } // The one probe for the configuration file: nothing outside this class asks which file a home directory diff --git a/src/Buildvana.Core.IO/Gitignore/GitignorePattern-matching.cs b/src/Buildvana.Core.IO/Gitignore/GitignorePattern-matching.cs index 1ee0445c..7a070348 100644 --- a/src/Buildvana.Core.IO/Gitignore/GitignorePattern-matching.cs +++ b/src/Buildvana.Core.IO/Gitignore/GitignorePattern-matching.cs @@ -134,10 +134,10 @@ private static bool MatchTokens( return tokenIndex == tokens.Length; } - private static bool MatchesChar(GitignoreToken token, char c, bool ignoreCase) => token.Kind switch { - GitignoreTokenKind.Literal => CharsEqual(token.Value, c, ignoreCase), - GitignoreTokenKind.AnyChar => true, - GitignoreTokenKind.CharClass => token.CharClass!.Matches(c, ignoreCase), + private static bool MatchesChar(GitignoreToken token, char c, bool ignoreCase) => token switch { + { Kind: GitignoreTokenKind.Literal } => CharsEqual(token.Value, c, ignoreCase), + { Kind: GitignoreTokenKind.AnyChar } => true, + { Kind: GitignoreTokenKind.CharClass, CharClass: { } charClass } => charClass.Matches(c, ignoreCase), _ => false, // AnyRun is consumed by MatchTokens and never reaches here. }; diff --git a/src/Buildvana.Core.Json/JsonHelper-InsertProperty.cs b/src/Buildvana.Core.Json/JsonHelper-InsertProperty.cs index 3fed62b9..578dce1c 100644 --- a/src/Buildvana.Core.Json/JsonHelper-InsertProperty.cs +++ b/src/Buildvana.Core.Json/JsonHelper-InsertProperty.cs @@ -111,11 +111,12 @@ private static bool TryFindParentObject(ref Utf8JsonReader reader, IReadOnlyList break; case JsonTokenType.StartObject or JsonTokenType.StartArray: - var pushed = pendingProperty is not null; - if (pushed) + var pushed = false; + if (pendingProperty is { } property) { - pathSegments.Add(pendingProperty!); + pathSegments.Add(property); pendingProperty = null; + pushed = true; } containerPushedSegment.Push(pushed); diff --git a/src/Buildvana.Core.Json/JsonHelper.cs b/src/Buildvana.Core.Json/JsonHelper.cs index 9281d295..e28e084c 100644 --- a/src/Buildvana.Core.Json/JsonHelper.cs +++ b/src/Buildvana.Core.Json/JsonHelper.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Text.Encodings.Web; using System.Text.Json; @@ -281,7 +282,8 @@ private static void TryRecordStringEdit( string propertyName, List edits) { - var currentValue = reader.GetString()!; + var currentValue = reader.GetString(); + Debug.Assert(currentValue is not null, "GetString returns null only for a null token, and the reader is on a string."); pathSegments.Add(propertyName); var newValue = rewriter(pathSegments, currentValue); pathSegments.RemoveAt(pathSegments.Count - 1); diff --git a/src/Buildvana.Core.Json/JsonKeyedObjectConverter.ListConverter`1.cs b/src/Buildvana.Core.Json/JsonKeyedObjectConverter.ListConverter`1.cs index b13db4ec..fe57d35a 100644 --- a/src/Buildvana.Core.Json/JsonKeyedObjectConverter.ListConverter`1.cs +++ b/src/Buildvana.Core.Json/JsonKeyedObjectConverter.ListConverter`1.cs @@ -4,6 +4,7 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; @@ -51,7 +52,8 @@ public override IReadOnlyList Read( var entries = new List<(string Key, JsonElement Value)>(); while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) { - var key = reader.GetString()!; + var key = reader.GetString(); + Debug.Assert(key is not null, "GetString returns null only for a null token, and the reader is on a property name."); _ = reader.Read(); var document = JsonDocument.ParseValue(ref reader); documents.Add(document); @@ -88,7 +90,9 @@ public override IReadOnlyList Read( buffer.ResetWrittenCount(); elementWriter.Reset(buffer); WriteElementJson(elementWriter, key, value); - result.Add(JsonSerializer.Deserialize(buffer.WrittenSpan, typeInfo)!); + var element = JsonSerializer.Deserialize(buffer.WrittenSpan, typeInfo); + Debug.Assert(element is not null, "WriteElementJson writes an object, and an object never deserializes as null."); + result.Add(element); } return result; @@ -123,11 +127,13 @@ public override void Write(Utf8JsonWriter writer, IReadOnlyList value, JsonSe private void WriteElementJson(Utf8JsonWriter writer, string key, JsonElement value) { + Debug.Assert(_keyJsonName is not null, "GetElementTypeInfo sets the key name."); writer.WriteStartObject(); - writer.WriteString(_keyJsonName!, key); + writer.WriteString(_keyJsonName, key); if (valuePropertyName is not null) { - writer.WritePropertyName(_valueJsonName!); + Debug.Assert(_valueJsonName is not null, "GetElementTypeInfo sets the value name for a value property."); + writer.WritePropertyName(_valueJsonName); value.WriteTo(writer); } else @@ -157,14 +163,17 @@ private void WriteElementJson(Utf8JsonWriter writer, string key, JsonElement val private void WriteElement(Utf8JsonWriter writer, JsonElement element, HashSet writtenKeys) { - if (!element.TryGetProperty(_keyJsonName!, out var keyElement) || keyElement.ValueKind != JsonValueKind.String) + Debug.Assert(_keyJsonName is not null, "GetElementTypeInfo sets the key name."); + var key = element.TryGetProperty(_keyJsonName, out var keyElement) && keyElement.ValueKind == JsonValueKind.String + ? keyElement.GetString() + : null; + if (key is null) { throw new JsonException($"The key property '{_keyJsonName}' must serialize as a non-null string."); } // Utf8JsonWriter validates structure only, so without this check two elements sharing a key would // produce a document this converter's own Read refuses or silently collapses. - var key = keyElement.GetString()!; if (!writtenKeys.Add(key)) { throw new JsonException($"Duplicate key '{key}'."); @@ -192,7 +201,8 @@ private void WriteElement(Utf8JsonWriter writer, JsonElement element, HashSet public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - var elementType = TryGetElementType(typeToConvert)!; - var attribute = elementType.GetCustomAttribute(inherit: false)!; + var keyedElementType = TryGetKeyedElementType(typeToConvert); + Debug.Assert(keyedElementType is not null, "The serializer calls CreateConverter only after CanConvert returns true."); + var (elementType, attribute) = keyedElementType.Value; // The attribute's CLR property names are resolved to JSON names by the converter itself, at first use, // from the element's JsonTypeInfo: the serializer's own metadata is right by construction under any // naming policy, TypeInfoResolver, or contract customization. Recomputing the names here from // reflection would silently disagree the moment a contract modifier renames a property. var converterType = typeof(ListConverter<>).MakeGenericType(elementType); - return (JsonConverter)Activator.CreateInstance(converterType, attribute.KeyPropertyName, attribute.ValuePropertyName)!; + var converter = (JsonConverter?)Activator.CreateInstance(converterType, attribute.KeyPropertyName, attribute.ValuePropertyName); + Debug.Assert(converter is not null, "Activator.CreateInstance returns null only for a nullable value type."); + return converter; } // Also serves JsonSchemaGenerator, which renders a keyed list from the same detection this factory // converts it by, so the parser and the schema cannot drift apart. - internal static Type? TryGetKeyedElementType(Type type) - => TryGetElementType(type) is { } elementType && elementType.IsDefined(typeof(JsonKeyedObjectAttribute), inherit: false) - ? elementType - : null; + internal static (Type ElementType, JsonKeyedObjectAttribute Attribute)? TryGetKeyedElementType(Type type) + => TryGetElementType(type) is { } elementType + && elementType.GetCustomAttribute(inherit: false) is { } attribute + ? (elementType, attribute) + : null; // Resolves the attribute's CLR property names to the JSON names the serializer will actually use, and // refuses the models the converter cannot read: a non-string key property, and key and value resolving to diff --git a/src/Buildvana.Core.Json/Schema/JsonSchemaGenerator-KeyedObjects.cs b/src/Buildvana.Core.Json/Schema/JsonSchemaGenerator-KeyedObjects.cs index a25ee25a..dbf308b0 100644 --- a/src/Buildvana.Core.Json/Schema/JsonSchemaGenerator-KeyedObjects.cs +++ b/src/Buildvana.Core.Json/Schema/JsonSchemaGenerator-KeyedObjects.cs @@ -18,6 +18,7 @@ partial class JsonSchemaGenerator // is synthesized here from the element type's own schema. private static JsonObject CreateKeyedObjectSchema( Type elementType, + JsonKeyedObjectAttribute attribute, JsonSerializerOptions options, NullabilityInfoContext nullabilityContext, HashSet keyedTypesInProgress) @@ -33,7 +34,6 @@ private static JsonObject CreateKeyedObjectSchema( try { - var attribute = elementType.GetCustomAttribute(inherit: false)!; var (keyJsonName, valueJsonName) = JsonKeyedObjectConverter.ResolveKeyedNames( options.GetTypeInfo(elementType), attribute.KeyPropertyName, diff --git a/src/Buildvana.Core.Json/Schema/JsonSchemaGenerator.cs b/src/Buildvana.Core.Json/Schema/JsonSchemaGenerator.cs index 40437f1a..bc402a62 100644 --- a/src/Buildvana.Core.Json/Schema/JsonSchemaGenerator.cs +++ b/src/Buildvana.Core.Json/Schema/JsonSchemaGenerator.cs @@ -157,10 +157,11 @@ private static JsonNode TransformSchemaNode( var keyedElementType = context.TypeInfo.Kind is JsonTypeInfoKind.None ? JsonKeyedObjectConverter.TryGetKeyedElementType(context.TypeInfo.Type) : null; - if (keyedElementType is not null) + if (keyedElementType is var (elementType, attribute)) { var keyedSchema = CreateKeyedObjectSchema( - keyedElementType, + elementType, + attribute, context.TypeInfo.Options, nullabilityContext, keyedTypesInProgress); @@ -477,9 +478,9 @@ private static void RemoveNullFromType(JsonObject schema) } } - if (typeArray.Count == 1) + if (typeArray is [{ } single]) { - schema["type"] = typeArray[0]!.GetValue(); + schema["type"] = single.GetValue(); } } diff --git a/src/Buildvana.Core.Json/Schema/JsonSchemaValidator.cs b/src/Buildvana.Core.Json/Schema/JsonSchemaValidator.cs index e4497074..8925d315 100644 --- a/src/Buildvana.Core.Json/Schema/JsonSchemaValidator.cs +++ b/src/Buildvana.Core.Json/Schema/JsonSchemaValidator.cs @@ -447,14 +447,13 @@ private static bool TypeMatches(JsonNode? instance, string type) "array" => kind is JsonValueKind.Array, "string" => kind is JsonValueKind.String, "number" => kind is JsonValueKind.Number, - "integer" => kind is JsonValueKind.Number && IsIntegral(instance!), + "integer" => kind is JsonValueKind.Number && instance is JsonValue value && IsIntegral(value), _ => true, // Unknown type keyword: do not fail on a constraint we do not understand. }; } - private static bool IsIntegral(JsonNode instance) + private static bool IsIntegral(JsonValue value) { - var value = instance.AsValue(); if (value.TryGetValue(out _)) { return true; diff --git a/src/Buildvana.Core.Testing/AssemblyExtensions.cs b/src/Buildvana.Core.Testing/AssemblyExtensions.cs new file mode 100644 index 00000000..a7a2a7ef --- /dev/null +++ b/src/Buildvana.Core.Testing/AssemblyExtensions.cs @@ -0,0 +1,37 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Linq; +using System.Reflection; +using CommunityToolkit.Diagnostics; + +namespace Buildvana.Core.Testing; + +/// +/// Provides extension methods for instances. +/// +#pragma warning disable CA1034 // Nested types should not be visible — false positive on C# 14 extension blocks; fixed in .NET 11, backport to .NET 10 requested in https://github.com/dotnet/sdk/issues/53984 +#pragma warning disable CA1708 // Identifiers should differ by more than case — false positive on classes with C# 14 extension blocks; fixed in .NET 11, https://github.com/dotnet/sdk/issues/51716 +public static class AssemblyExtensions +{ + extension(Assembly @this) + { + /// + /// Gets the value of an assembly metadata item, such as one a test project supplies with an + /// AssemblyMetadata item. + /// + /// The key of the metadata item. + /// The value of the first metadata item with the given key. + /// The assembly has no metadata item with the given key, or the + /// item has no value. + public string GetMetadata(string key) + { + Guard.IsNotNull(key); + var value = @this.GetCustomAttributes() + .FirstOrDefault(attribute => string.Equals(attribute.Key, key, StringComparison.Ordinal)) + ?.Value; + return value ?? throw new InvalidOperationException($"{@this.GetName().Name} has no metadata item \"{key}\" with a value."); + } + } +} diff --git a/src/Buildvana.Core.Testing/TempGitRepo.cs b/src/Buildvana.Core.Testing/TempGitRepo.cs index 5a467713..835a95ad 100644 --- a/src/Buildvana.Core.Testing/TempGitRepo.cs +++ b/src/Buildvana.Core.Testing/TempGitRepo.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Text; @@ -278,7 +279,9 @@ public void LockRemoteReference(string canonicalName, string name = "origin") { ArgumentException.ThrowIfNullOrEmpty(canonicalName); var lockPath = Path.Combine([_remotePaths[name], .. $"{canonicalName}.lock".Split('/')]); - _ = Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); + var lockDirectory = Path.GetDirectoryName(lockPath); + Debug.Assert(lockDirectory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); + _ = Directory.CreateDirectory(lockDirectory); File.WriteAllBytes(lockPath, []); } diff --git a/src/Buildvana.Core.Testing/TempHome.cs b/src/Buildvana.Core.Testing/TempHome.cs index ae0b56b3..28504648 100644 --- a/src/Buildvana.Core.Testing/TempHome.cs +++ b/src/Buildvana.Core.Testing/TempHome.cs @@ -2,6 +2,7 @@ // See the LICENSE file in the project root for full license information. using System; +using System.Diagnostics; using System.IO; using Buildvana.Core.HomeDirectory; using CommunityToolkit.Diagnostics; @@ -47,7 +48,9 @@ public string GetFullPath(string name) public void WriteFile(string name, string content) { var path = GetFullPath(name); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var directory = Path.GetDirectoryName(path); + Debug.Assert(directory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); + _ = Directory.CreateDirectory(directory); File.WriteAllText(path, content); } diff --git a/src/Buildvana.Sdk.SourceGenerators/Internal/ConstantValueParser.cs b/src/Buildvana.Sdk.SourceGenerators/Internal/ConstantValueParser.cs index 55c0050f..cd9b4656 100644 --- a/src/Buildvana.Sdk.SourceGenerators/Internal/ConstantValueParser.cs +++ b/src/Buildvana.Sdk.SourceGenerators/Internal/ConstantValueParser.cs @@ -36,13 +36,13 @@ internal static class ConstantValueParser public static bool TryParse(string? str, out object? result) { - if (string.IsNullOrEmpty(str)) + if (str is null or "") { result = null; return true; } - if (str!.Length > 1 && str[0] == '"' && str[str.Length - 1] == '"') + if (str.Length > 1 && str[0] == '"' && str[str.Length - 1] == '"') { result = str.Substring(1, str.Length - 2).Replace("\"\"", "\""); return true; diff --git a/src/Buildvana.Sdk.Tasks/Tasks/WritePackagePinDump.cs b/src/Buildvana.Sdk.Tasks/Tasks/WritePackagePinDump.cs index 12e52566..b164499d 100644 --- a/src/Buildvana.Sdk.Tasks/Tasks/WritePackagePinDump.cs +++ b/src/Buildvana.Sdk.Tasks/Tasks/WritePackagePinDump.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Security.Cryptography; @@ -124,7 +125,9 @@ private static void Write(string path, string content) // contract (see docs/sdk-diagnostics.md): every message a task issues must carry a BVSDK code. try { - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var directory = Path.GetDirectoryName(path); + Debug.Assert(directory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); + _ = Directory.CreateDirectory(directory); File.WriteAllText(path, content); } catch (Exception e) when (e.IsIORelatedException) diff --git a/src/Buildvana.Tool/Build/BuildPipeline.cs b/src/Buildvana.Tool/Build/BuildPipeline.cs index d118fa3c..7a074247 100644 --- a/src/Buildvana.Tool/Build/BuildPipeline.cs +++ b/src/Buildvana.Tool/Build/BuildPipeline.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -135,7 +136,8 @@ private Task CleanAsync(CancellationToken cancellationToken) foreach (var project in _solution.Model.SolutionProjects) { cancellationToken.ThrowIfCancellationRequested(); - var projectDirectory = Path.GetDirectoryName(_solution.ResolveProjectPath(project))!; + var projectDirectory = Path.GetDirectoryName(_solution.ResolveProjectPath(project)); + Debug.Assert(projectDirectory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); UserDirectory.DeleteIfExists(Path.Combine(projectDirectory, "bin"), _reporter); UserDirectory.DeleteIfExists(Path.Combine(projectDirectory, "obj"), _reporter); } diff --git a/src/Buildvana.Tool/Infrastructure/Execution/CommandRegistry.cs b/src/Buildvana.Tool/Infrastructure/Execution/CommandRegistry.cs index 10ef6df4..aac1f672 100644 --- a/src/Buildvana.Tool/Infrastructure/Execution/CommandRegistry.cs +++ b/src/Buildvana.Tool/Infrastructure/Execution/CommandRegistry.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics; using System.Linq; using System.Reflection; using Buildvana.Core; @@ -304,11 +305,15 @@ private static void LinkAlias(CommandNode root, IReadOnlyList aliasPath, var canonicalNode = root; for (var depth = 0; depth < aliasPath.Count; depth++) { - aliasNode = aliasNode.FindChild(aliasPath[depth])!; + var aliasChild = aliasNode.FindChild(aliasPath[depth]); + Debug.Assert(aliasChild is not null, "AddPath created every node of the alias path."); + aliasNode = aliasChild; // An alias may be shorter than the path it names, as `version` is for `version show`. Its last // node then names the canonical node of the same depth, which for `version` is itself. - canonicalNode = canonicalNode.FindChild(canonicalPath[Math.Min(depth, canonicalPath.Count - 1)])!; + var canonicalChild = canonicalNode.FindChild(canonicalPath[Math.Min(depth, canonicalPath.Count - 1)]); + Debug.Assert(canonicalChild is not null, "AddPath created every node of the canonical path."); + canonicalNode = canonicalChild; if (aliasNode.IsOnCanonicalPath || ReferenceEquals(aliasNode, canonicalNode)) { continue; diff --git a/src/Buildvana.Tool/Services/DeclaredApiFiles/DeclaredApiFilesService.cs b/src/Buildvana.Tool/Services/DeclaredApiFiles/DeclaredApiFilesService.cs index 2f6d2980..7dcfa015 100644 --- a/src/Buildvana.Tool/Services/DeclaredApiFiles/DeclaredApiFilesService.cs +++ b/src/Buildvana.Tool/Services/DeclaredApiFiles/DeclaredApiFilesService.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Text; @@ -167,7 +168,9 @@ private IEnumerable TransferAllApisToShipped(string prefix, string kind) (string UnshippedPath, string ShippedPath)? GetPair(string shippedPath) { - var unshippedPath = Path.Combine(Path.GetDirectoryName(shippedPath)!, unshippedFileName); + var directory = Path.GetDirectoryName(shippedPath); + Debug.Assert(directory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); + var unshippedPath = Path.Combine(directory, unshippedFileName); return UserFile.Exists(unshippedPath) ? (unshippedPath, shippedPath) : null; } } diff --git a/src/Buildvana.Tool/Services/Dependencies/OrphanDetector.cs b/src/Buildvana.Tool/Services/Dependencies/OrphanDetector.cs index 698150c8..00f5558d 100644 --- a/src/Buildvana.Tool/Services/Dependencies/OrphanDetector.cs +++ b/src/Buildvana.Tool/Services/Dependencies/OrphanDetector.cs @@ -123,10 +123,9 @@ private static bool IsCentralPin(DependencyPin pin) IReadOnlyList evaluations) { return [ - .. evaluations - .Where(static dump => !string.IsNullOrEmpty(dump.ProjectAssetsFile)) - .GroupBy(static dump => dump.ProjectFullPath, StringComparer.OrdinalIgnoreCase) - .Select(static grouped => (grouped.Key, grouped.First().ProjectAssetsFile!)), + .. PackagePinDumps.WithAssetsFile(evaluations) + .GroupBy(static dump => dump.Evaluation.ProjectFullPath, StringComparer.OrdinalIgnoreCase) + .Select(static grouped => (grouped.Key, grouped.First().AssetsFilePath)), ]; } diff --git a/src/Buildvana.Tool/Services/Dependencies/OverrideLifecycle.cs b/src/Buildvana.Tool/Services/Dependencies/OverrideLifecycle.cs index 23911124..c5df3d59 100644 --- a/src/Buildvana.Tool/Services/Dependencies/OverrideLifecycle.cs +++ b/src/Buildvana.Tool/Services/Dependencies/OverrideLifecycle.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -177,7 +178,8 @@ private void Accept( // A project using central package management takes its version from the central file, so that one // version answers for every project that needs it. One managing its own versions takes it inline. case OverrideOutcome.Override when project.ManagesVersionsCentrally: - state.AddCentral(packageId, decision.Version!); + Debug.Assert(decision.Version is not null, "OverrideSelector states a version for every override."); + state.AddCentral(packageId, decision.Version); state.AddPromotion(project.ProjectFullPath, packageId, version: null); break; case OverrideOutcome.Override: @@ -187,7 +189,8 @@ private void Accept( state.AddPromotion(project.ProjectFullPath, packageId, version: null); break; default: - verdicts.Add(Verdict(project, packageId, request, decision.Reason!)); + Debug.Assert(decision.Reason is not null, "OverrideSelector states a reason for every decision that writes nothing."); + verdicts.Add(Verdict(project, packageId, request, decision.Reason)); break; } } diff --git a/src/Buildvana.Tool/Services/Dependencies/OverrideProject.cs b/src/Buildvana.Tool/Services/Dependencies/OverrideProject.cs index fcbd8a62..1e8fc637 100644 --- a/src/Buildvana.Tool/Services/Dependencies/OverrideProject.cs +++ b/src/Buildvana.Tool/Services/Dependencies/OverrideProject.cs @@ -64,9 +64,8 @@ public static IReadOnlyList Create( var moved = MovedCentralPins(packages); var gone = RemovedCentralPins(removed); return [ - .. evaluations - .Where(static dump => !string.IsNullOrEmpty(dump.ProjectAssetsFile)) - .GroupBy(static dump => dump.ProjectFullPath, StringComparer.OrdinalIgnoreCase) + .. PackagePinDumps.WithAssetsFile(evaluations) + .GroupBy(static dump => dump.Evaluation.ProjectFullPath, StringComparer.OrdinalIgnoreCase) .Select(grouped => Fold(home, grouped, moved, gone)) .OrderBy(static project => project.ProjectFullPath, StringComparer.Ordinal), ]; @@ -114,14 +113,14 @@ private static Dictionary> RemovedCentralPins(IReadOnlyL // reports more findings and blocks more promotions, and no automatic step should be the bolder reading. private static OverrideProject Fold( IHomeDirectoryProvider home, - IGrouping evaluations, + IGrouping evaluations, Dictionary> moved, Dictionary> gone) { var centralPins = new Dictionary(StringComparer.OrdinalIgnoreCase); var auditLevel = PackageVulnerabilitySeverity.Critical; var managesCentrally = false; - foreach (var evaluation in evaluations) + foreach (var (evaluation, _) in evaluations) { managesCentrally |= evaluation.ManagePackageVersionsCentrally; auditLevel = (PackageVulnerabilitySeverity)Math.Min((int)auditLevel, (int)AuditLevelOf(evaluation)); @@ -130,7 +129,7 @@ private static OverrideProject Fold( return new OverrideProject { ProjectFullPath = evaluations.Key, - AssetsFilePath = evaluations.First().ProjectAssetsFile!, + AssetsFilePath = evaluations.First().AssetsFilePath, AuditLevel = auditLevel, ManagesVersionsCentrally = managesCentrally, CentralPins = managesCentrally ? centralPins : new Dictionary(StringComparer.OrdinalIgnoreCase), diff --git a/src/Buildvana.Tool/Services/Dependencies/PackagePinDumps.cs b/src/Buildvana.Tool/Services/Dependencies/PackagePinDumps.cs new file mode 100644 index 00000000..6bcefd12 --- /dev/null +++ b/src/Buildvana.Tool/Services/Dependencies/PackagePinDumps.cs @@ -0,0 +1,31 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using Buildvana.Core.Dependencies; + +namespace Buildvana.Tool.Services.Dependencies; + +/// +/// Reads the evaluations a pin dump answered with. +/// +internal static class PackagePinDumps +{ + /// + /// Pairs each evaluation that states an assets file with the path of that file. + /// + /// The evaluations the pin dump answered with. + /// The evaluations that state an assets file, each with its path, in the order given. An + /// evaluation that states none is left out: Buildvana SDK never reached its project. + public static IEnumerable<(PackagePinDump Evaluation, string AssetsFilePath)> WithAssetsFile( + IEnumerable evaluations) + { + foreach (var evaluation in evaluations) + { + if (evaluation.ProjectAssetsFile is { Length: > 0 } assetsFilePath) + { + yield return (evaluation, assetsFilePath); + } + } + } +} diff --git a/src/Buildvana.Tool/Services/Dependencies/PackagePinReader.cs b/src/Buildvana.Tool/Services/Dependencies/PackagePinReader.cs index 574300d5..b0960701 100644 --- a/src/Buildvana.Tool/Services/Dependencies/PackagePinReader.cs +++ b/src/Buildvana.Tool/Services/Dependencies/PackagePinReader.cs @@ -107,18 +107,19 @@ private void AddPin( MetadataPolicy = EvaluatedMetadata.Stated(item.UpdatePolicy), }; - pins.Add(key, ClassifyDeclaration(pin, versionOverride is not null)); + pins.Add(key, ClassifyDeclaration(pin, item.ItemType, versionOverride is not null)); } - private DependencyPin ClassifyDeclaration(DependencyPin pin, bool statesVersionOverride) + private DependencyPin ClassifyDeclaration(DependencyPin pin, string itemType, bool statesVersionOverride) { if (statesVersionOverride) { return pin with { Management = PinManagement.VersionOverride }; } - if (pin.Management != PinManagement.Managed - || _declarations.StatesVersion(pin.DeclaringFile, pin.ItemType!, pin.Id, pin.VersionText)) + var isSettled = pin.Management != PinManagement.Managed + || _declarations.StatesVersion(pin.DeclaringFile, itemType, pin.Id, pin.VersionText); + if (isSettled) { return pin; } diff --git a/src/Buildvana.Tool/Services/Dependencies/PackagePinWriter.cs b/src/Buildvana.Tool/Services/Dependencies/PackagePinWriter.cs index f3371150..bc08e714 100644 --- a/src/Buildvana.Tool/Services/Dependencies/PackagePinWriter.cs +++ b/src/Buildvana.Tool/Services/Dependencies/PackagePinWriter.cs @@ -42,7 +42,12 @@ public void Write(IReadOnlyList pins) if (items.Length > 0) { var targets = PinWriting.TargetsOf(items); - var itemTypes = items.Select(static pin => pin.Pin.ItemType!).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var itemTypes = items + .Select(static pin => pin.Pin.ItemType) + .OfType() + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var rewritten = MsBuildPinEditor.RewritePins( path, itemTypes, @@ -54,11 +59,9 @@ public void Write(IReadOnlyList pins) if (directives.Length > 0) { var targets = PinWriting.TargetsOf(directives); - - // The editor calls back only for directives that carry a version, so VersionText is never null here. var rewritten = AppDirectiveEditor.RewriteVersions( path, - directive => PinWriting.Restate(targets, itemType: null, directive.Id, directive.VersionText!)); + (directive, versionText) => PinWriting.Restate(targets, itemType: null, directive.Id, versionText)); EnsureEdited(rewritten, path, "moves"); } @@ -82,7 +85,12 @@ public void Remove(IReadOnlyList pins) { var path = home.GetFullPath(file.Key); var keys = PinWriting.KeysOf(file); - var itemTypes = file.Select(static pin => pin.ItemType!).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var itemTypes = file + .Select(static pin => pin.ItemType) + .OfType() + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var removed = MsBuildPinEditor.RemovePins( path, itemTypes, diff --git a/src/Buildvana.Tool/Services/Dependencies/PinMove.cs b/src/Buildvana.Tool/Services/Dependencies/PinMove.cs new file mode 100644 index 00000000..2dd03a72 --- /dev/null +++ b/src/Buildvana.Tool/Services/Dependencies/PinMove.cs @@ -0,0 +1,13 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using NuGet.Versioning; + +namespace Buildvana.Tool.Services.Dependencies; + +/// +/// A pin a run moves, with the version it moves to. +/// +/// The pin, as the repository states it. +/// The version the pin moves to. +internal readonly record struct PinMove(DependencyPin Pin, NuGetVersion Target); diff --git a/src/Buildvana.Tool/Services/Dependencies/PinWriting.cs b/src/Buildvana.Tool/Services/Dependencies/PinWriting.cs index e65636de..ba892930 100644 --- a/src/Buildvana.Tool/Services/Dependencies/PinWriting.cs +++ b/src/Buildvana.Tool/Services/Dependencies/PinWriting.cs @@ -24,21 +24,29 @@ internal static class PinWriting /// Names the pins a run moves. /// /// What the run made of a scope's pins. - /// The pins that move. - public static IEnumerable Moving(IEnumerable pins) - => pins.Where(static pin => pin is { State: PinResolutionState.Updated, Target: not null }); + /// The pins that move, each with the version it moves to. + public static IEnumerable Moving(IEnumerable pins) + { + foreach (var pin in pins) + { + if (pin is { State: PinResolutionState.Updated, Target: { } target }) + { + yield return new PinMove(pin.Pin, target); + } + } + } /// /// Indexes the versions a set of pins moves to, by what identifies each pin in its file. /// /// The pins that move. /// The lookup a rewrite consults for every declaration it walks past. - public static Dictionary TargetsOf(IEnumerable pins) + public static Dictionary TargetsOf(IEnumerable pins) { var targets = new Dictionary(); foreach (var pin in pins) { - targets[KeyOf(pin.Pin.ItemType, pin.Pin.Id, pin.Pin.VersionText)] = pin.Target!; + targets[KeyOf(pin.Pin.ItemType, pin.Pin.Id, pin.Pin.VersionText)] = pin.Target; } return targets; diff --git a/src/Buildvana.Tool/Services/Dependencies/SdkPinWriter.cs b/src/Buildvana.Tool/Services/Dependencies/SdkPinWriter.cs index d3d52da8..197fb745 100644 --- a/src/Buildvana.Tool/Services/Dependencies/SdkPinWriter.cs +++ b/src/Buildvana.Tool/Services/Dependencies/SdkPinWriter.cs @@ -47,7 +47,7 @@ public void Write(IReadOnlyList pins) var targets = PinWriting.TargetsOf(file); var rewritten = AppDirectiveEditor.RewriteVersions( path, - directive => PinWriting.Restate(targets, itemType: null, directive.Id, directive.VersionText!)); + (directive, versionText) => PinWriting.Restate(targets, itemType: null, directive.Id, versionText)); EnsureWritten(rewritten, path); } @@ -61,12 +61,12 @@ public void Write(IReadOnlyList pins) private static void EnsureWritten(bool written, string path) => BuildFailedException.ThrowIfNot(written, $"{path} no longer states a project SDK version this run moves."); - private void WriteGlobalJson(string path, IReadOnlyList pins) + private void WriteGlobalJson(string path, IReadOnlyList pins) { var targets = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var pin in pins) { - targets[pin.Pin.Id] = pin.Target!; + targets[pin.Pin.Id] = pin.Target; } var rewritten = jsonHelper.RewriteStringValues( diff --git a/src/Buildvana.Tool/Services/Dependencies/SidecarReader.cs b/src/Buildvana.Tool/Services/Dependencies/SidecarReader.cs index 4c42ec9f..bdb3fedd 100644 --- a/src/Buildvana.Tool/Services/Dependencies/SidecarReader.cs +++ b/src/Buildvana.Tool/Services/Dependencies/SidecarReader.cs @@ -51,8 +51,21 @@ public IReadOnlyList Read() return entries; } - private static IEnumerable ItemsOf(XDocument document, string itemType) - => document.Descendants().Where(element => string.Equals(element.Name.LocalName, itemType, StringComparison.Ordinal)); + // bv writes both values as attributes, and bv is the only writer these files have. + private static IEnumerable EntriesOf( + XDocument document, + string relativePath, + string itemType) + { + foreach (var element in document.Descendants()) + { + var isItem = string.Equals(element.Name.LocalName, itemType, StringComparison.Ordinal); + if (isItem && (string?)element.Attribute("Include") is { Length: > 0 } id) + { + yield return new TransitiveOverrideEntry(id, (string?)element.Attribute("Version"), relativePath); + } + } + } private void ReadFile(List entries, string relativePath, string itemType) { @@ -62,16 +75,10 @@ private void ReadFile(List entries, string relativePath return; } - // bv writes both values as attributes, and bv is the only writer these files have. - var items = ItemsOf(document, itemType) - .Select(static element => (Id: (string?)element.Attribute("Include"), Version: (string?)element.Attribute("Version"))) - .Where(static item => !string.IsNullOrEmpty(item.Id)) - .OrderBy(static item => item.Id, StringComparer.OrdinalIgnoreCase); + var fileEntries = EntriesOf(document, relativePath, itemType) + .OrderBy(static entry => entry.PackageId, StringComparer.OrdinalIgnoreCase); - foreach (var (id, version) in items) - { - entries.Add(new TransitiveOverrideEntry(id!, version, relativePath)); - } + entries.AddRange(fileEntries); } // A file bv wrote and something else mangled is not worth a failed command: the next apply run rewrites diff --git a/src/Buildvana.Tool/Services/Dependencies/SidecarWriter.cs b/src/Buildvana.Tool/Services/Dependencies/SidecarWriter.cs index 4e8acbdc..d1d44fbd 100644 --- a/src/Buildvana.Tool/Services/Dependencies/SidecarWriter.cs +++ b/src/Buildvana.Tool/Services/Dependencies/SidecarWriter.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Security; @@ -80,7 +81,10 @@ DO NOT EDIT. Do not ignore in source control. """; private static string CentralItem(PackageOverride entry) - => $""" """; + { + Debug.Assert(entry.Version is not null, "Every central override states the version it pins."); + return $""" """; + } private static string ProjectItem(PackageOverride entry) { diff --git a/src/Buildvana.Tool/Services/Dependencies/ToolPinUpdater.cs b/src/Buildvana.Tool/Services/Dependencies/ToolPinUpdater.cs index 4f9496df..256f0cec 100644 --- a/src/Buildvana.Tool/Services/Dependencies/ToolPinUpdater.cs +++ b/src/Buildvana.Tool/Services/Dependencies/ToolPinUpdater.cs @@ -41,7 +41,7 @@ public async Task UpdateAsync(IReadOnlyList pins, CancellationTok Guard.IsNotNull(pins); foreach (var pin in PinWriting.Moving(pins)) { - var version = pin.Target!.ToNormalizedString(); + var version = pin.Target.ToNormalizedString(); reporter.Info($"Updating tool {pin.Pin.Id} to {version}..."); _ = await processRunner.RunAsync( DotNetMuxer.Path, @@ -53,8 +53,8 @@ public async Task UpdateAsync(IReadOnlyList pins, CancellationTok // The CLI has a downgrade guard of its own, and it stays armed unless the run is an assisted manual // edit: a policy-driven update never lowers a pin, so a downgrade the CLI blocks there is a bug caught. - private static bool IsDowngrade(PinResolution pin) - => pin.Pin.Version is { } current && VersionComparer.VersionRelease.Compare(pin.Target!, current) < 0; + private static bool IsDowngrade(PinMove pin) + => pin.Pin.Version is { } current && VersionComparer.VersionRelease.Compare(pin.Target, current) < 0; private static string[] ArgumentsFor(DependencyPin pin, string version, bool isDowngrade) => isDowngrade diff --git a/src/Buildvana.Tool/Services/Git/GitService.cs b/src/Buildvana.Tool/Services/Git/GitService.cs index 08b63bd9..d865b1ee 100644 --- a/src/Buildvana.Tool/Services/Git/GitService.cs +++ b/src/Buildvana.Tool/Services/Git/GitService.cs @@ -99,33 +99,26 @@ public bool TagExists(string tag) /// public (SemanticVersion? Latest, SemanticVersion? LatestStable) GetLatestVersions() { - var versions = _repository.Tags - .Select(x => SemanticVersion.TryParse(x.FriendlyName, out var version) - ? (x.Target.Sha, Version: version) - : (Sha: null!, Version: null!)) - .Where(x => x.Sha is not null) - .ToDictionary(); - + var versionsByCommit = GetVersionsByCommit(); SemanticVersion? latest = null; - SemanticVersion? latestStable = null; foreach (var commit in _repository.Head.Commits) { - if (!versions.TryGetValue(commit.Sha, out var version)) + if (!versionsByCommit.TryGetValue(commit.Sha, out var versions)) { continue; } - latest ??= version; - if (version.IsPrerelease) + // A commit can carry several version tags. Its highest version and its highest stable version are the + // ones that count. + latest ??= versions.Max(); + var latestStable = versions.Where(static version => !version.IsPrerelease).Max(); + if (latestStable is not null) { - continue; + return (latest, latestStable); } - - latestStable = version; - break; } - return (latest, latestStable); + return (latest, null); } /// @@ -314,6 +307,29 @@ public void FetchTags(string remote) return signature is null ? null : new(signature.Name, signature.Email); } + private Dictionary> GetVersionsByCommit() + { + var versionsByCommit = new Dictionary>(StringComparer.Ordinal); + foreach (var tag in _repository.Tags) + { + if (!SemanticVersion.TryParse(tag.FriendlyName, out var version)) + { + continue; + } + + var sha = tag.Target.Sha; + if (!versionsByCommit.TryGetValue(sha, out var versions)) + { + versions = []; + versionsByCommit.Add(sha, versions); + } + + versions.Add(version); + } + + return versionsByCommit; + } + private void Push(string remote, string refSpec) { using var gitRemote = GetRemote(remote); diff --git a/src/Buildvana.Tool/Services/Git/IGitService.cs b/src/Buildvana.Tool/Services/Git/IGitService.cs index 3880b2df..48e627cc 100644 --- a/src/Buildvana.Tool/Services/Git/IGitService.cs +++ b/src/Buildvana.Tool/Services/Git/IGitService.cs @@ -67,6 +67,8 @@ internal interface IGitService /// /// A tuple of the latest version and the latest stable version. /// + /// When a commit carries several version tags, the highest one is the version of the commit, and the + /// highest stable one is its stable version. /// If no version tag is found in commit history, this method returns a tuple of two s. /// If no stable version tag is found in commit history, this method returns a tuple of the latest /// version and . diff --git a/src/Buildvana.Tool/Services/Hooks/HookRunner.cs b/src/Buildvana.Tool/Services/Hooks/HookRunner.cs index c8270c9c..089a0b46 100644 --- a/src/Buildvana.Tool/Services/Hooks/HookRunner.cs +++ b/src/Buildvana.Tool/Services/Hooks/HookRunner.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using System.IO; using System.Text.Json; using System.Threading; @@ -119,7 +120,9 @@ private async Task RunHookAsync( var json = JsonSerializer.Serialize(args, args.GetType(), BuildvanaJsonContext.Default); _reporter.Trace($"Hook {hookName}: args: {json}"); var argsPath = _home.GetFullPath(WellKnownPaths.GetHookArgsFile(context, @event)); - _ = UserDirectory.CreateDirectory(Path.GetDirectoryName(argsPath)!); + var argsDirectory = Path.GetDirectoryName(argsPath); + Debug.Assert(argsDirectory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); + _ = UserDirectory.CreateDirectory(argsDirectory); await UserFile.WriteAllTextAsync(argsPath, json, cancellationToken).ConfigureAwait(false); _reporter.Info($"Running hook {hookName}..."); var result = await _appRunner.RunFileBasedAppAsync( diff --git a/src/Buildvana.Tool/Services/Hooks/PostUpdateHookArgsFactory.cs b/src/Buildvana.Tool/Services/Hooks/PostUpdateHookArgsFactory.cs index 98485bc0..cc7bf22a 100644 --- a/src/Buildvana.Tool/Services/Hooks/PostUpdateHookArgsFactory.cs +++ b/src/Buildvana.Tool/Services/Hooks/PostUpdateHookArgsFactory.cs @@ -47,9 +47,8 @@ public PostUpdateHookArgs Create(DependencyResolution resolution, bool check) Tools = ResultsOf(resolution.Tools), Packages = ResultsOf(resolution.Packages.Where(static pin => pin.Pin.GroupCaption is null)), AdditionalPackages = [ - .. resolution.Packages - .Where(static pin => pin.Pin.GroupCaption is not null) - .GroupBy(static pin => pin.Pin.GroupCaption!) + .. GroupedPins(resolution.Packages) + .GroupBy(static grouped => grouped.Caption, static grouped => grouped.Pin) .Select(static group => new AdditionalPackagesResult { Caption = group.Key, Results = ResultsOf(group) }), ], @@ -59,6 +58,17 @@ .. resolution.Packages }; } + private static IEnumerable<(string Caption, PinResolution Pin)> GroupedPins(IEnumerable pins) + { + foreach (var pin in pins) + { + if (pin.Pin.GroupCaption is { } caption) + { + yield return (caption, pin); + } + } + } + private static IReadOnlyList OverridesOf(IReadOnlyList entries) { return [ diff --git a/src/Buildvana.Tool/Services/SelfUpdate/FamilyPinUpdater.cs b/src/Buildvana.Tool/Services/SelfUpdate/FamilyPinUpdater.cs index 2004da31..7b3e9426 100644 --- a/src/Buildvana.Tool/Services/SelfUpdate/FamilyPinUpdater.cs +++ b/src/Buildvana.Tool/Services/SelfUpdate/FamilyPinUpdater.cs @@ -116,10 +116,11 @@ public IReadOnlyList StampPins(IReadOnlyList pins, NuGetVersi } else { - // The editor calls back only for directives that carry a version, so VersionText is never null here. _ = AppDirectiveEditor.RewriteVersions( path, - directive => BuildvanaFamily.Contains(directive.Id) ? PinVersionText.Restate(directive.VersionText!, target) : null); + (directive, versionText) => BuildvanaFamily.Contains(directive.Id) + ? PinVersionText.Restate(versionText, target) + : null); } } diff --git a/src/Buildvana.Tool/Services/SelfUpdate/SelfVersionService.cs b/src/Buildvana.Tool/Services/SelfUpdate/SelfVersionService.cs index 3adab593..381dac00 100644 --- a/src/Buildvana.Tool/Services/SelfUpdate/SelfVersionService.cs +++ b/src/Buildvana.Tool/Services/SelfUpdate/SelfVersionService.cs @@ -324,7 +324,8 @@ private static void EnsureNoUnforcedDowngrade( // downloads the version in one go — hand-editing the manifest would leave the pin unrestored. The choice // between update and install is keyed on entry presence, mirroring the CLI's own contract: update rewrites // an existing entry, and install adds one. (An entry with an unusable version never reaches this point; - // EnsureUsableManifestEntry rejects it up front.) The manifest itself is created with `dotnet new + // EnsureUsableManifestEntry rejects it up front. An entry is therefore present exactly when it has a version, + // and the code tests the version.) The manifest itself is created with `dotnet new // tool-manifest`, in the home directory, when there is none. Left to `dotnet tool install`, the CLI picks // the directory by its own markers — a `.git` directory anywhere above, then a solution file — which can be // an ancestor of the home directory. --tool-manifest names the file for the same reason. The template writes @@ -340,7 +341,6 @@ private async Task PinToolManifestAsync( { var targetText = target.ToNormalizedString(); var currentPin = manifestPin.Version; - var hasEntry = manifestPin.HasEntry; var isUnchanged = currentPin is not null && VersionComparer.VersionRelease.Equals(currentPin, target); // The dotnet CLI has a downgrade guard of its own: `tool update` refuses to move a tool to a lower @@ -360,12 +360,12 @@ private async Task PinToolManifestAsync( } string[] args = isDowngrade ? ["tool", "update", ToolPackageId, "--version", targetText, "--allow-downgrade"] - : hasEntry ? ["tool", "update", ToolPackageId, "--version", targetText] + : currentPin is not null ? ["tool", "update", ToolPackageId, "--version", targetText] : ["tool", "install", ToolPackageId, "--version", targetText]; await RunDotNetAsync([.. args, "--tool-manifest", ToolManifest.FileName], cancellationToken).ConfigureAwait(false); - return !hasEntry ? $"{ToolPackageId}: {targetText} (tool manifest, added)" + return currentPin is null ? $"{ToolPackageId}: {targetText} (tool manifest, added)" : isUnchanged ? ManifestLineWhenUnchanged(target) - : $"{ToolPackageId}: {currentPin!.ToNormalizedString()} -> {targetText} (tool manifest)"; + : $"{ToolPackageId}: {currentPin.ToNormalizedString()} -> {targetText} (tool manifest)"; } // A handed-off run finds the manifest already at the target, because the bv that handed off moved it @@ -384,11 +384,12 @@ private string ManifestLineWhenUnchanged(NuGetVersion target) return $"{ToolPackageId}: {targetText} (tool manifest, added)"; } - var isMove = NuGetVersion.TryParse(_handedOffFrom, out var previousPin) - && !VersionComparer.VersionRelease.Equals(previousPin, target); - return isMove - ? $"{ToolPackageId}: {previousPin!.ToNormalizedString()} -> {targetText} (tool manifest)" - : $"{ToolPackageId}: {targetText} (tool manifest, unchanged)"; + if (NuGetVersion.TryParse(_handedOffFrom, out var previousPin) && !VersionComparer.VersionRelease.Equals(previousPin, target)) + { + return $"{ToolPackageId}: {previousPin.ToNormalizedString()} -> {targetText} (tool manifest)"; + } + + return $"{ToolPackageId}: {targetText} (tool manifest, unchanged)"; } private async Task RunDotNetAsync(string[] args, CancellationToken cancellationToken) @@ -506,8 +507,7 @@ private void WritePin(string version) } var root = _jsonHelper.LoadObject(path); - var hasSdksSection = root.TryGetPropertyValue(MsbuildSdksPropertyName, out var sdksNode) && sdksNode is JsonObject; - if (!hasSdksSection) + if (!root.TryGetPropertyValue(MsbuildSdksPropertyName, out var sdksNode) || sdksNode is not JsonObject sdks) { var section = new JsonObject { [SdkPackageId] = version }; var inserted = _jsonHelper.InsertProperty(path, [], MsbuildSdksPropertyName, section); @@ -516,7 +516,7 @@ private void WritePin(string version) return; } - if (((JsonObject)sdksNode!).ContainsKey(SdkPackageId)) + if (sdks.ContainsKey(SdkPackageId)) { var rewritten = _jsonHelper.RewriteStringValues(path, (propertyPath, _) => IsPinPath(propertyPath) ? version : null); BuildFailedException.ThrowIfNot(rewritten, $"{path} has a '{MsbuildSdksPropertyName}.{SdkPackageId}' property that is not a string."); diff --git a/src/Buildvana.Tool/Services/Solution/SolutionContextExtensions.cs b/src/Buildvana.Tool/Services/Solution/SolutionContextExtensions.cs index 9405b966..dd38d57d 100644 --- a/src/Buildvana.Tool/Services/Solution/SolutionContextExtensions.cs +++ b/src/Buildvana.Tool/Services/Solution/SolutionContextExtensions.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using System.IO; using CommunityToolkit.Diagnostics; using Microsoft.VisualStudio.SolutionPersistence.Model; @@ -19,7 +20,15 @@ internal static class SolutionContextExtensions /// /// Gets the absolute path of the directory containing the solution file. /// - public string SolutionDirectory => Path.GetDirectoryName(@this.SolutionPath)!; + public string SolutionDirectory + { + get + { + var directory = Path.GetDirectoryName(@this.SolutionPath); + Debug.Assert(directory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); + return directory; + } + } /// /// Resolves a path that is relative to into an absolute path, diff --git a/src/Buildvana.Tool/Utilities/AppDirectiveEditor.cs b/src/Buildvana.Tool/Utilities/AppDirectiveEditor.cs index 4b08f4a1..5f702bc9 100644 --- a/src/Buildvana.Tool/Utilities/AppDirectiveEditor.cs +++ b/src/Buildvana.Tool/Utilities/AppDirectiveEditor.cs @@ -45,12 +45,13 @@ public static IReadOnlyList ReadDirectives(string path) /// place. /// /// The path of the file to edit. - /// Called once per directive that carries a version, in document order; - /// returns the new version text for the directive, or to leave it alone. Text - /// ordinally equal to the current version leaves the directive alone too. + /// Called once per directive that carries a version, in document order, + /// with the directive and its version text; returns the new version text for the directive, or + /// to leave it alone. Text ordinally equal to the current version leaves the + /// directive alone too. /// if the file was modified; otherwise, . /// The file is written only when it was modified. - public static bool RewriteVersions(string path, Func getNewVersionText) + public static bool RewriteVersions(string path, Func getNewVersionText) { Guard.IsNotNull(path); Guard.IsNotNull(getNewVersionText); @@ -63,7 +64,7 @@ public static bool RewriteVersions(string path, Func getN continue; } - var newVersionText = getNewVersionText(match.Directive); + var newVersionText = getNewVersionText(match.Directive, versionText); if (newVersionText is not null && !string.Equals(newVersionText, versionText, StringComparison.Ordinal)) { edits.Add(new TextEdit(match.VersionStart, versionText.Length, newVersionText)); diff --git a/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigFactoryTests.cs b/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigFactoryTests.cs index 65b16f5b..6fa50778 100644 --- a/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigFactoryTests.cs +++ b/tests/Buildvana.Core.Configuration.Tests/BuildvanaConfigFactoryTests.cs @@ -268,10 +268,12 @@ public async Task Create_Feeds_MapVerbatim() var config = BuildvanaConfigFactory.Create(json, null); - await Assert.That(config.NuGet.Feeds.Prerelease!.Source).IsEqualTo("https://prerelease.example/v3/index.json"); - await Assert.That(config.NuGet.Feeds.Prerelease.ApiKeyEnv).IsEqualTo("PRE_KEY"); - await Assert.That(config.NuGet.Feeds.Release!.Source).IsEqualTo("https://nuget.example/v3/index.json"); - await Assert.That(config.NuGet.Feeds.Release.ApiKeyEnv).IsEqualTo("KEY"); + var prerelease = await Assert.That(config.NuGet.Feeds.Prerelease).IsNotNull(); + await Assert.That(prerelease.Source).IsEqualTo("https://prerelease.example/v3/index.json"); + await Assert.That(prerelease.ApiKeyEnv).IsEqualTo("PRE_KEY"); + var release = await Assert.That(config.NuGet.Feeds.Release).IsNotNull(); + await Assert.That(release.Source).IsEqualTo("https://nuget.example/v3/index.json"); + await Assert.That(release.ApiKeyEnv).IsEqualTo("KEY"); } // A prerelease with no feed of its own pushes to the release feed: the factory resolves the fallback, @@ -289,8 +291,9 @@ public async Task Create_Feeds_PrereleaseFallsBackToReleaseFeed() var config = BuildvanaConfigFactory.Create(json, null); - await Assert.That(config.NuGet.Feeds.Prerelease).IsSameReferenceAs(config.NuGet.Feeds.Release!); - await Assert.That(config.NuGet.Feeds.Release!.Source).IsEqualTo("https://nuget.example/v3/index.json"); + var release = await Assert.That(config.NuGet.Feeds.Release).IsNotNull(); + await Assert.That(config.NuGet.Feeds.Prerelease).IsSameReferenceAs(release); + await Assert.That(release.Source).IsEqualTo("https://nuget.example/v3/index.json"); } [Test] @@ -302,8 +305,9 @@ public async Task Create_GitIdentity_MapsNameAndEmail() var config = BuildvanaConfigFactory.Create(json, null); - await Assert.That(config.Git.Identity!.Name).IsEqualTo("Buildvana Bot"); - await Assert.That(config.Git.Identity.Email).IsEqualTo("bot@buildvana.invalid"); + var identity = await Assert.That(config.Git.Identity).IsNotNull(); + await Assert.That(identity.Name).IsEqualTo("Buildvana Bot"); + await Assert.That(identity.Email).IsEqualTo("bot@buildvana.invalid"); } // Configured patterns extend the built-in scope rather than replace it: hooks are file-based apps by diff --git a/tests/Buildvana.Core.Configuration.Tests/BuildvanaJsonConfigExampleTests.cs b/tests/Buildvana.Core.Configuration.Tests/BuildvanaJsonConfigExampleTests.cs index 06e7e0d5..bbdd4d64 100644 --- a/tests/Buildvana.Core.Configuration.Tests/BuildvanaJsonConfigExampleTests.cs +++ b/tests/Buildvana.Core.Configuration.Tests/BuildvanaJsonConfigExampleTests.cs @@ -90,7 +90,7 @@ public async Task Generate_ExampleBesideMembers_Throws() ["properties"] = new JsonObject { ["member"] = new JsonObject { ["default"] = true } }, }); - var problem = await ProblemOf(root).ConfigureAwait(false); + var problem = ProblemOf(root); await Assert.That(problem).IsEqualTo( "The setting 'section' states an example, and its value is a section: an object with members, " @@ -111,7 +111,7 @@ public async Task Generate_ExampleBesideMemberNames_Throws() ["additionalProperties"] = new JsonObject { ["type"] = "object" }, }); - var problem = await ProblemOf(root).ConfigureAwait(false); + var problem = ProblemOf(root); await Assert.That(problem).IsEqualTo( "The setting 'groups' states an example, and its value is a section: an object with members, " @@ -138,7 +138,7 @@ public async Task Generate_ExampleOnAReferenceToASection_Throws() }, }; - var problem = await ProblemOf(root).ConfigureAwait(false); + var problem = ProblemOf(root); await Assert.That(problem).IsEqualTo( "The setting 'section' states an example, and its value is a section: an object with members, " @@ -178,7 +178,7 @@ public async Task Generate_KeyedObjectWithoutAKeyExample_Throws() ["additionalProperties"] = new JsonObject { ["type"] = "object" }, }); - var problem = await ProblemOf(root).ConfigureAwait(false); + var problem = ProblemOf(root); await Assert.That(problem).IsEqualTo( "The keyed object 'groups' states no example member name. Annotate the key property of its " @@ -200,7 +200,7 @@ public async Task Generate_KeyedObjectWithAnEmptyKeySchema_Throws() ["propertyNames"] = new JsonObject(), }); - var problem = await ProblemOf(root).ConfigureAwait(false); + var problem = ProblemOf(root); await Assert.That(problem).IsEqualTo( "The keyed object 'groups' states no example member name. Annotate the key property of its " @@ -214,7 +214,7 @@ public async Task Generate_KeyedObjectWithoutAValueSchema_Throws() "groups", new JsonObject { ["propertyNames"] = new JsonObject { ["examples"] = new JsonArray { "My group" } } }); - var problem = await ProblemOf(root).ConfigureAwait(false); + var problem = ProblemOf(root); await Assert.That(problem).IsEqualTo("The keyed object 'groups' declares no value schema."); } @@ -226,7 +226,7 @@ public async Task Generate_SettingWithNoValue_Throws() { var root = SchemaOf("setting", new JsonObject { ["type"] = "string" }); - var problem = await ProblemOf(root).ConfigureAwait(false); + var problem = ProblemOf(root); await Assert.That(problem).IsEqualTo( "The setting 'setting' states neither an example nor a default, and is not a container. " @@ -239,7 +239,7 @@ public async Task Generate_OverlongDescription_Throws() var description = Words(35); var root = SchemaOf("setting", new JsonObject { ["description"] = description, ["default"] = true }); - var problem = await ProblemOf(root).ConfigureAwait(false); + var problem = ProblemOf(root); await Assert.That(problem).IsEqualTo( $"The description \"{description}\" needs 3 comment lines, past the 2 a description gets. A " @@ -317,7 +317,7 @@ public async Task ResolveReference_FollowsADocumentLocalPointer() var resolved = BuildvanaJsonConfigExample.ResolveReference(Reference("#/properties/args"), root, "args"); - await Assert.That(resolved["type"]!.GetValue()).IsEqualTo("array"); + await Assert.That(resolved["type"]?.GetValue()).IsEqualTo("array"); } // A member name holding a slash or a tilde is escaped in a pointer, and has to survive the trip back. @@ -329,8 +329,8 @@ public async Task ResolveReference_UnescapesPointerTokens() var slash = BuildvanaJsonConfigExample.ResolveReference(Reference("#/properties/a~1b"), root, "a/b"); var tilde = BuildvanaJsonConfigExample.ResolveReference(Reference("#/properties/c~0d"), root, "c~d"); - await Assert.That(slash["type"]!.GetValue()).IsEqualTo("string"); - await Assert.That(tilde["type"]!.GetValue()).IsEqualTo("boolean"); + await Assert.That(slash["type"]?.GetValue()).IsEqualTo("string"); + await Assert.That(tilde["type"]?.GetValue()).IsEqualTo("boolean"); } [Test] @@ -366,14 +366,8 @@ private static JsonObject SchemaOf(string name, JsonObject member) => new() { ["properties"] = new JsonObject { [name] = member } }; // The message the generator refuses a schema with. - private static async Task ProblemOf(JsonObject root) - { - string Act() => BuildvanaJsonConfigExample.Generate(root); - - var exception = await Assert.That(Act).Throws(); - - return exception!.Message; - } + private static string ProblemOf(JsonObject root) + => Assert.Throws(() => BuildvanaJsonConfigExample.Generate(root)).Message; // Four-letter words joined by single spaces, so the text is (5 * count - 1) characters long and a greedy // wrap has somewhere to break. diff --git a/tests/Buildvana.Core.Configuration.Tests/BuildvanaJsonConfigProviderTests.cs b/tests/Buildvana.Core.Configuration.Tests/BuildvanaJsonConfigProviderTests.cs index b416b914..ad218a2d 100644 --- a/tests/Buildvana.Core.Configuration.Tests/BuildvanaJsonConfigProviderTests.cs +++ b/tests/Buildvana.Core.Configuration.Tests/BuildvanaJsonConfigProviderTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using System.Text; using Buildvana.Core; using Buildvana.Core.Configuration; @@ -32,7 +33,7 @@ public async Task Config_ValidConfig_Loads() { Write(dir, "buildvana.jsonc", """{ "release": { "branches": ["main"] } }"""); var config = NewProvider(dir).Config; - await Assert.That(config.Release!.Branches!.Count).IsEqualTo(1); + await Assert.That(config.Release?.Branches?.Count).IsEqualTo(1); } finally { @@ -124,7 +125,7 @@ public async Task Config_ReadBeforePath_FindsTheFile() Write(dir, "buildvana.jsonc", """{ "release": { "branches": ["main"] } }"""); var provider = NewProvider(dir); - await Assert.That(provider.Config.Release!.Branches!.Count).IsEqualTo(1); + await Assert.That(provider.Config.Release?.Branches?.Count).IsEqualTo(1); await Assert.That(provider.Path).IsEqualTo(Path.Combine(dir, "buildvana.jsonc")); } finally @@ -159,9 +160,8 @@ public async Task Config_BothFilesPresent_ThrowsNamingBothWithoutDiagnostics() { Write(dir, "buildvana.json", "{}"); Write(dir, "buildvana.jsonc", "{}"); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Message).Contains(Path.Combine(dir, "buildvana.json")); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Message).Contains(Path.Combine(dir, "buildvana.json")); await Assert.That(exception.Message).Contains(Path.Combine(dir, "buildvana.jsonc")); await Assert.That(exception.Diagnostics.Count).IsEqualTo(0); } @@ -178,9 +178,8 @@ public async Task Config_InvalidJson_ReportsBV1100() try { Write(dir, "buildvana.jsonc", "{ not json "); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics.Count).IsEqualTo(1); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics.Count).IsEqualTo(1); await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1100"); } finally @@ -196,9 +195,8 @@ public async Task Config_SchemaViolation_ReportsCodeAndPosition() try { Write(dir, "buildvana.jsonc", "{\n \"release\": { \"branches\": [\"main\", null] }\n}"); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics.Count).IsEqualTo(1); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics.Count).IsEqualTo(1); await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1101"); await Assert.That(exception.Diagnostics[0].Line).IsEqualTo(2); } @@ -215,9 +213,8 @@ public async Task Config_UnknownProperty_ReportsBV1103() try { Write(dir, "buildvana.jsonc", """{ "bogus": 1 }"""); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics[0].Code).IsEqualTo("BV1103"); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1103"); } finally { @@ -234,9 +231,8 @@ public async Task Config_FeedWithoutApiKeyEnv_ReportsBV1104() try { Write(dir, "buildvana.jsonc", """{ "nuget": { "feeds": { "release": { "source": "https://nuget.example" } } } }"""); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics[0].Code).IsEqualTo("BV1104"); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1104"); await Assert.That(exception.Diagnostics[0].Message).Contains("apiKeyEnv"); } finally @@ -252,9 +248,8 @@ public async Task Config_IdentityWithoutEmail_ReportsBV1104() try { Write(dir, "buildvana.jsonc", """{ "git": { "identity": { "name": "Buildvana Bot" } } }"""); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics[0].Code).IsEqualTo("BV1104"); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1104"); await Assert.That(exception.Diagnostics[0].Message).Contains("email"); } finally @@ -273,9 +268,8 @@ public async Task Config_EmptyFeedSource_ReportsBV1106() try { Write(dir, "buildvana.jsonc", """{ "nuget": { "feeds": { "release": { "source": "", "apiKeyEnv": "KEY" } } } }"""); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics.Count).IsEqualTo(1); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics.Count).IsEqualTo(1); await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1106"); } finally @@ -291,9 +285,8 @@ public async Task Config_WhitespaceIdentityName_ReportsBV1107() try { Write(dir, "buildvana.jsonc", """{ "git": { "identity": { "name": " ", "email": "bot@buildvana.invalid" } } }"""); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics.Count).IsEqualTo(1); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics.Count).IsEqualTo(1); await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1107"); } finally @@ -336,7 +329,7 @@ public async Task LoadFile_KnownPath_LoadsItRegardlessOfSiblings() Write(dir, "buildvana.json", """{ "release": { "branches": ["main"] } }"""); Write(dir, "buildvana.jsonc", "{}"); var config = BuildvanaJsonConfigProvider.LoadFile(Path.Combine(dir, "buildvana.json")); - await Assert.That(config.Release!.Branches!.Count).IsEqualTo(1); + await Assert.That(config.Release?.Branches?.Count).IsEqualTo(1); } finally { @@ -359,8 +352,8 @@ public async Task Config_TypedContent_LoadsEnumsAndNestedSections() """; Write(dir, "buildvana.jsonc", content); var config = NewProvider(dir).Config; - await Assert.That(config.Versioning!.AssemblyVersionPrecision).IsEqualTo(AssemblyVersionPrecision.Minor); - await Assert.That(config.NuGet!.Feeds!.Release!.ApiKeyEnv).IsEqualTo("KEY"); + await Assert.That(config.Versioning?.AssemblyVersionPrecision).IsEqualTo(AssemblyVersionPrecision.Minor); + await Assert.That(config.NuGet?.Feeds?.Release?.ApiKeyEnv).IsEqualTo("KEY"); } finally { @@ -394,9 +387,8 @@ public async Task Config_BomPrefixedFile_DoesNotOffsetPositions() try { Write(dir, "buildvana.jsonc", "{\n \"release\": { \"branches\": [42] }\n}", bom: true); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics[0].Line).IsEqualTo(2); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics[0].Line).IsEqualTo(2); } finally { @@ -425,17 +417,18 @@ public async Task Config_DependenciesSection_LoadsKeyedListsInDocumentOrder() """; Write(dir, "buildvana.jsonc", content); - var dependencies = NewProvider(dir).Config.Dependencies!; - - await Assert.That(dependencies.Scopes!.NetSdk).IsEqualTo("lts"); - await Assert.That(dependencies.Scopes.Packages).IsEqualTo("major-"); - await Assert.That(dependencies.Scopes.Sdks).IsNull(); - await Assert.That(dependencies.Policies![0].Pattern).IsEqualTo("Microsoft.CodeAnalysis.*"); - await Assert.That(dependencies.Policies[0].Policy).IsEqualTo("minor"); - await Assert.That(dependencies.Policies[1].Pattern).IsEqualTo("StyleCop.Analyzers"); - await Assert.That(dependencies.AdditionalPackages![0].Caption).IsEqualTo("SDK-injected packages"); - await Assert.That(dependencies.AdditionalPackages[0].Items).IsEqualTo("BV_PackageVersion"); - await Assert.That(dependencies.AdditionalPackages[0].Policy).IsNull(); + var dependencies = await Assert.That(NewProvider(dir).Config.Dependencies).IsNotNull(); + + var scopes = await Assert.That(dependencies.Scopes).IsNotNull(); + await Assert.That(scopes.NetSdk).IsEqualTo("lts"); + await Assert.That(scopes.Packages).IsEqualTo("major-"); + await Assert.That(scopes.Sdks).IsNull(); + await Assert.That(dependencies.Policies?[0].Pattern).IsEqualTo("Microsoft.CodeAnalysis.*"); + await Assert.That(dependencies.Policies?[0].Policy).IsEqualTo("minor"); + await Assert.That(dependencies.Policies?[1].Pattern).IsEqualTo("StyleCop.Analyzers"); + await Assert.That(dependencies.AdditionalPackages?[0].Caption).IsEqualTo("SDK-injected packages"); + await Assert.That(dependencies.AdditionalPackages?[0].Items).IsEqualTo("BV_PackageVersion"); + await Assert.That(dependencies.AdditionalPackages?[0].Policy).IsNull(); } finally { @@ -464,9 +457,8 @@ public async Task Config_DuplicatePolicyPattern_ReportsBV1108AtTheRepeat() """; Write(dir, "buildvana.jsonc", content); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics.Count).IsEqualTo(1); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics.Count).IsEqualTo(1); await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1108"); await Assert.That(exception.Diagnostics[0].Message).Contains("A*"); await Assert.That(exception.Diagnostics[0].Line).IsEqualTo(6); @@ -489,9 +481,8 @@ public async Task Config_DuplicateEnvironmentVariable_ReportsBV1108() try { Write(dir, "buildvana.jsonc", """{ "dotnet": { "all": { "env": { "A": "1", "A": "2" } } } }"""); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics[0].Code).IsEqualTo("BV1108"); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1108"); } finally { @@ -508,9 +499,8 @@ public async Task Config_BlankPolicyPattern_ReportsBV1106() try { Write(dir, "buildvana.jsonc", """{ "dependencies": { "policies": { "": "minor" } } }"""); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics[0].Code).IsEqualTo("BV1106"); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1106"); await Assert.That(exception.Diagnostics[0].Line).IsEqualTo(1); // The name that is blank, not the value four characters past it. @@ -540,9 +530,8 @@ public async Task Config_BlankAdditionalPackagesCaption_ReportsBV1107() """; Write(dir, "buildvana.jsonc", content); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics.Count).IsEqualTo(1); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics.Count).IsEqualTo(1); await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1107"); await Assert.That(exception.Diagnostics[0].Line).IsEqualTo(4); await Assert.That(exception.Diagnostics[0].Column).IsEqualTo(7); @@ -561,9 +550,8 @@ public async Task Config_PackagePolicyWithNetSdkKind_ReportsBV1102() try { Write(dir, "buildvana.jsonc", """{ "dependencies": { "policies": { "Some.Package": "lts" } } }"""); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics.Count).IsEqualTo(1); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics.Count).IsEqualTo(1); await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1102"); await Assert.That(exception.Diagnostics[0].Message).Contains("\"lts\""); } @@ -581,9 +569,8 @@ public async Task Config_NetSdkScopeWithPackageKind_ReportsBV1102() try { Write(dir, "buildvana.jsonc", """{ "dependencies": { "scopes": { "netsdk": "exact" } } }"""); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics.Count).IsEqualTo(1); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics.Count).IsEqualTo(1); await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1102"); } finally @@ -603,9 +590,8 @@ public async Task Config_AdditionalPackageGroupWithoutItems_ReportsBV1104() """; Write(dir, "buildvana.jsonc", content); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics[0].Code).IsEqualTo("BV1104"); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1104"); await Assert.That(exception.Diagnostics[0].Message).Contains("items"); } finally @@ -633,9 +619,8 @@ public async Task Config_AdditionalPackageGroupRestatingItsCaption_ReportsBV1105 """; Write(dir, "buildvana.jsonc", content); - var exception = Catch(dir); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Diagnostics[0].Code).IsEqualTo("BV1105"); + var exception = await Assert.That(Catch(dir)).IsNotNull(); + await Assert.That(exception.Diagnostics[0].Code).IsEqualTo("BV1105"); } finally { @@ -655,7 +640,9 @@ private static string NewDir() private static void Write(string dir, string fileName, string content, bool bom = false) { var path = Path.Combine(dir, fileName); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var directory = Path.GetDirectoryName(path); + Debug.Assert(directory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); + _ = Directory.CreateDirectory(directory); File.WriteAllText(path, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: bom)); } diff --git a/tests/Buildvana.Core.Configuration.Tests/BuildvanaJsonConfigSchemaTests.cs b/tests/Buildvana.Core.Configuration.Tests/BuildvanaJsonConfigSchemaTests.cs index af458dbd..8e4807b4 100644 --- a/tests/Buildvana.Core.Configuration.Tests/BuildvanaJsonConfigSchemaTests.cs +++ b/tests/Buildvana.Core.Configuration.Tests/BuildvanaJsonConfigSchemaTests.cs @@ -10,13 +10,13 @@ // kind added to either enum without a matching schema value fails here. internal sealed class BuildvanaJsonConfigSchemaTests { - private static readonly JsonNode Dependencies - = JsonNode.Parse(BuildvanaJsonConfigSchema.Generate())!["properties"]!["dependencies"]!["properties"]!; + private static readonly JsonNode? Dependencies + = JsonNode.Parse(BuildvanaJsonConfigSchema.Generate())?["properties"]?["dependencies"]?["properties"]; [Test] public async Task Generate_NetSdkScope_EnumeratesEveryNetSdkPolicyString() { - var netSdk = AllowedValues(Dependencies["scopes"]!["properties"]!["netsdk"]); + var netSdk = AllowedValues(Dependencies?["scopes"]?["properties"]?["netsdk"]); await Assert.That(netSdk.SequenceEqual(NetSdkPolicyStrings())).IsTrue(); } @@ -25,19 +25,19 @@ public async Task Generate_NetSdkScope_EnumeratesEveryNetSdkPolicyString() public async Task Generate_PackagePositions_EnumerateEveryPackagePolicyString() { var expected = PackagePolicyStrings(); - var scopes = Dependencies["scopes"]!["properties"]!; + var scopes = Dependencies?["scopes"]?["properties"]; - await Assert.That(AllowedValues(scopes["sdks"]).SequenceEqual(expected)).IsTrue(); - await Assert.That(AllowedValues(scopes["tools"]).SequenceEqual(expected)).IsTrue(); - await Assert.That(AllowedValues(scopes["packages"]).SequenceEqual(expected)).IsTrue(); - await Assert.That(AllowedValues(Dependencies["policies"]!["additionalProperties"]).SequenceEqual(expected)).IsTrue(); - await Assert.That(AllowedValues(GroupProperties()["policy"]).SequenceEqual(expected)).IsTrue(); + await Assert.That(AllowedValues(scopes?["sdks"]).SequenceEqual(expected)).IsTrue(); + await Assert.That(AllowedValues(scopes?["tools"]).SequenceEqual(expected)).IsTrue(); + await Assert.That(AllowedValues(scopes?["packages"]).SequenceEqual(expected)).IsTrue(); + await Assert.That(AllowedValues(Dependencies?["policies"]?["additionalProperties"]).SequenceEqual(expected)).IsTrue(); + await Assert.That(AllowedValues(GroupProperties()?["policy"]).SequenceEqual(expected)).IsTrue(); } // The caption travels as the member name, so the value object must not restate it. [Test] public async Task Generate_AdditionalPackageGroup_ForbidsTheCaptionInsideTheValue() - => await Assert.That(GroupProperties()["caption"]!.GetValueKind()).IsEqualTo(JsonValueKind.False); + => await Assert.That(GroupProperties()?["caption"]?.GetValueKind()).IsEqualTo(JsonValueKind.False); private static List NetSdkPolicyStrings() { @@ -63,9 +63,10 @@ private static List PackagePolicyStrings() return strings; } - private static JsonNode GroupProperties() - => Dependencies["additionalPackages"]!["additionalProperties"]!["properties"]!; + private static JsonNode? GroupProperties() + => Dependencies?["additionalPackages"]?["additionalProperties"]?["properties"]; - private static List AllowedValues(JsonNode? schema) - => [.. ((JsonArray)schema!["enum"]!).Select(static value => value!.GetValue())]; + // A missing node yields no values, and a null element a null string, so either fails the comparison. + private static List AllowedValues(JsonNode? schema) + => [.. schema?["enum"]?.AsArray().Select(static value => value?.GetValue()) ?? []]; } diff --git a/tests/Buildvana.Core.Configuration.Tests/ConfigurationPageTests.cs b/tests/Buildvana.Core.Configuration.Tests/ConfigurationPageTests.cs index 14b57669..0f9f334a 100644 --- a/tests/Buildvana.Core.Configuration.Tests/ConfigurationPageTests.cs +++ b/tests/Buildvana.Core.Configuration.Tests/ConfigurationPageTests.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using System.Text.Json.Nodes; using Buildvana.Core.Configuration; using Buildvana.Core.Testing; @@ -17,10 +16,7 @@ internal sealed class ConfigurationPageTests // What the page writes for a setting the schema states no default for. private const string NoDefault = "none"; - private static readonly string RepositoryRoot = typeof(ConfigurationPageTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RepositoryRoot") - .Value!; + private static readonly string RepositoryRoot = typeof(ConfigurationPageTests).Assembly.GetMetadata("RepositoryRoot"); private static readonly JsonObject Schema = (JsonObject)BuildvanaJsonConfigSchema.GenerateNode(); @@ -70,10 +66,15 @@ private static DocumentationPage LoadPage() // says whether the member is a section. private static IEnumerable<(string Name, string? Default)> Settings(JsonObject schema, string path) { - foreach (var (name, node) in (JsonObject)schema["properties"]!) + if (schema["properties"] is not JsonObject properties) + { + yield break; + } + + foreach (var (name, node) in properties) { - var member = (JsonObject)node!; var memberPath = path.Length == 0 ? name : $"{path}.{name}"; + var member = node?.AsObject() ?? throw new InvalidOperationException($"The schema states null for {memberPath}."); var declaring = member["$ref"] is null ? member : BuildvanaJsonConfigExample.ResolveReference(member, Schema, memberPath); diff --git a/tests/Buildvana.Core.Configuration.Tests/RepositoryConfigFilesTests.cs b/tests/Buildvana.Core.Configuration.Tests/RepositoryConfigFilesTests.cs index 09b607da..87da0dbc 100644 --- a/tests/Buildvana.Core.Configuration.Tests/RepositoryConfigFilesTests.cs +++ b/tests/Buildvana.Core.Configuration.Tests/RepositoryConfigFilesTests.cs @@ -1,10 +1,10 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; using Buildvana.Core.Configuration; +using Buildvana.Core.Testing; // The repository's own configuration files, checked against the model in this branch. The repository root // reaches the test through an assembly metadata item the test project supplies, as Buildvana.Sdk.Tests does @@ -28,10 +28,7 @@ internal sealed class RepositoryConfigFilesTests AllowTrailingCommas = true, }; - private static readonly string RepositoryRoot = typeof(RepositoryConfigFilesTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RepositoryRoot") - .Value!; + private static readonly string RepositoryRoot = typeof(RepositoryConfigFilesTests).Assembly.GetMetadata("RepositoryRoot"); // The schema built from the model in this branch, which is what LoadFile validates against too. No // committed schema file is read here: a rebase moves the model, and every check below moves with it. @@ -156,25 +153,27 @@ private static bool IsSchemaMember(string line) private static string SchemaUrlFor(string reference) => $"https://raw.githubusercontent.com/Tenacom/Buildvana/{reference}/schemas/buildvana.schema.json"; - private static async Task SchemaReferenceOfAsync(string fileName) + private static async Task SchemaReferenceOfAsync(string fileName) { var file = await ReadJsonAsync(fileName).ConfigureAwait(false); - return file[SchemaMemberName]!.GetValue(); + return file[SchemaMemberName]?.GetValue(); } private static async Task PinnedToolVersionAsync() { var manifest = await ReadJsonAsync(ToolManifestFileName).ConfigureAwait(false); + var version = await Assert.That(manifest["tools"]?[ToolPackageId]?["version"]).IsNotNull(); - return manifest["tools"]![ToolPackageId]!["version"]!.GetValue(); + return version.GetValue(); } private static async Task ReadJsonAsync(string fileName) { var text = await File.ReadAllTextAsync(PathOf(fileName)).ConfigureAwait(false); + var node = await Assert.That(JsonNode.Parse(text, documentOptions: ReaderOptions)).IsNotNull(); - return (JsonObject)JsonNode.Parse(text, documentOptions: ReaderOptions)!; + return node.AsObject(); } // Every "description" keyword in a schema document, wherever it sits. diff --git a/tests/Buildvana.Core.Configuration.Tests/ToolDiagnosticsPageTests.cs b/tests/Buildvana.Core.Configuration.Tests/ToolDiagnosticsPageTests.cs index 09e58a5a..a3f36f79 100644 --- a/tests/Buildvana.Core.Configuration.Tests/ToolDiagnosticsPageTests.cs +++ b/tests/Buildvana.Core.Configuration.Tests/ToolDiagnosticsPageTests.cs @@ -12,10 +12,7 @@ internal sealed class ToolDiagnosticsPageTests { private const string SectionHeading = "Configuration (1100-1199)"; - private static readonly string RepositoryRoot = typeof(ToolDiagnosticsPageTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RepositoryRoot") - .Value!; + private static readonly string RepositoryRoot = typeof(ToolDiagnosticsPageTests).Assembly.GetMetadata("RepositoryRoot"); [Test] public async Task ConfigurationSection_ListsTheCodesOfDiagnosticCodes() @@ -24,7 +21,8 @@ public async Task ConfigurationSection_ListsTheCodesOfDiagnosticCodes() var listed = page.GetTableColumn(SectionHeading, "Code"); var declared = typeof(DiagnosticCodes) .GetFields(BindingFlags.Public | BindingFlags.Static) - .Select(static field => (string)field.GetRawConstantValue()!); + .Select(static field => field.GetRawConstantValue()) + .OfType(); await Assert.That(Join(listed)).IsEqualTo(Join(declared)); } diff --git a/tests/Buildvana.Core.ConsoleOutput.Tests/ReporterExtensionsTests.cs b/tests/Buildvana.Core.ConsoleOutput.Tests/ReporterExtensionsTests.cs index 846f3cef..0d7184a2 100644 --- a/tests/Buildvana.Core.ConsoleOutput.Tests/ReporterExtensionsTests.cs +++ b/tests/Buildvana.Core.ConsoleOutput.Tests/ReporterExtensionsTests.cs @@ -68,6 +68,7 @@ public async Task Report_NullFormat_Throws() using var reporter = new StringWriterReporter(Verbosity.Diagnostic); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning + // ! Null on purpose: testing null detection await Assert.That(() => reporter.Report(MessageLevel.Info, (CompositeFormat)null!)).Throws(); } } diff --git a/tests/Buildvana.Core.HomeDirectory.Tests/HomeDirectoryDiscoveryTests.cs b/tests/Buildvana.Core.HomeDirectory.Tests/HomeDirectoryDiscoveryTests.cs index e42d2dda..bbc2ecd0 100644 --- a/tests/Buildvana.Core.HomeDirectory.Tests/HomeDirectoryDiscoveryTests.cs +++ b/tests/Buildvana.Core.HomeDirectory.Tests/HomeDirectoryDiscoveryTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using Buildvana.Core.HomeDirectory; internal sealed class HomeDirectoryDiscoveryTests @@ -118,7 +119,9 @@ private static string NewDir() private static void WriteFile(string root, string relativePath) { var path = Path.Combine(root, relativePath); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var directory = Path.GetDirectoryName(path); + Debug.Assert(directory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); + _ = Directory.CreateDirectory(directory); File.WriteAllText(path, string.Empty); } diff --git a/tests/Buildvana.Core.IO.Tests/FileFinderTests.cs b/tests/Buildvana.Core.IO.Tests/FileFinderTests.cs index b078f536..e4e0ff15 100644 --- a/tests/Buildvana.Core.IO.Tests/FileFinderTests.cs +++ b/tests/Buildvana.Core.IO.Tests/FileFinderTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using Buildvana.Core.IO; internal sealed class FileFinderTests @@ -272,7 +273,9 @@ private static string CreateTempTree() private static void WriteFile(string root, string relativePath, string content = "") { var path = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var directory = Path.GetDirectoryName(path); + Debug.Assert(directory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); + _ = Directory.CreateDirectory(directory); File.WriteAllText(path, content); } } diff --git a/tests/Buildvana.Core.IO.Tests/GitignoreFileTests.cs b/tests/Buildvana.Core.IO.Tests/GitignoreFileTests.cs index 5e52ad8c..5a220c8a 100644 --- a/tests/Buildvana.Core.IO.Tests/GitignoreFileTests.cs +++ b/tests/Buildvana.Core.IO.Tests/GitignoreFileTests.cs @@ -8,6 +8,7 @@ internal sealed class GitignoreFileTests [Test] public async Task Parse_WithNullLines_ThrowsRaw() { + // ! Null on purpose: testing null detection static GitignoreFile Act() => GitignoreFile.Parse(null!); await Assert.That(Act).Throws(); diff --git a/tests/Buildvana.Core.IO.Tests/GitignorePatternMatchTests.cs b/tests/Buildvana.Core.IO.Tests/GitignorePatternMatchTests.cs index d607ee6a..d61b680f 100644 --- a/tests/Buildvana.Core.IO.Tests/GitignorePatternMatchTests.cs +++ b/tests/Buildvana.Core.IO.Tests/GitignorePatternMatchTests.cs @@ -151,14 +151,15 @@ public async Task Matches_EscapedTrailingSpace_IsLiteral() [Test] public async Task Matches_WithEmptyPath_ReturnsFalse() { - var pattern = GitignorePattern.TryParse("*")!; + var pattern = await Assert.That(GitignorePattern.TryParse("*")).IsNotNull(); await Assert.That(pattern.Matches([], isDirectory: false, ignoreCase: false)).IsFalse(); } private static bool Match(string patternText, string path, bool isDirectory = false, bool ignoreCase = false) { - var pattern = GitignorePattern.TryParse(patternText)!; + var pattern = GitignorePattern.TryParse(patternText) + ?? throw new InvalidOperationException($"'{patternText}' parses to no pattern."); return pattern.Matches(path.Split('/'), isDirectory, ignoreCase); } } diff --git a/tests/Buildvana.Core.IO.Tests/GitignorePatternParseTests.cs b/tests/Buildvana.Core.IO.Tests/GitignorePatternParseTests.cs index d3239b8b..19ed3d4e 100644 --- a/tests/Buildvana.Core.IO.Tests/GitignorePatternParseTests.cs +++ b/tests/Buildvana.Core.IO.Tests/GitignorePatternParseTests.cs @@ -8,6 +8,7 @@ internal sealed class GitignorePatternParseTests [Test] public async Task TryParse_WithNullLine_ThrowsRaw() { + // ! Null on purpose: testing null detection static GitignorePattern? Act() => GitignorePattern.TryParse(null!); await Assert.That(Act).Throws(); @@ -50,7 +51,7 @@ public async Task TryParse_WithEscapedHash_YieldsPattern() [Test] public async Task TryParse_WithNegation_SetsIsNegated() { - var pattern = GitignorePattern.TryParse("!foo")!; + var pattern = await Assert.That(GitignorePattern.TryParse("!foo")).IsNotNull(); await Assert.That(pattern.IsNegated).IsTrue(); await Assert.That(pattern.IsDirectoryOnly).IsFalse(); @@ -59,7 +60,7 @@ public async Task TryParse_WithNegation_SetsIsNegated() [Test] public async Task TryParse_WithTrailingSlash_SetsIsDirectoryOnly() { - var pattern = GitignorePattern.TryParse("foo/")!; + var pattern = await Assert.That(GitignorePattern.TryParse("foo/")).IsNotNull(); await Assert.That(pattern.IsDirectoryOnly).IsTrue(); await Assert.That(pattern.IsNegated).IsFalse(); @@ -68,7 +69,7 @@ public async Task TryParse_WithTrailingSlash_SetsIsDirectoryOnly() [Test] public async Task TryParse_PreservesOriginalText() { - var pattern = GitignorePattern.TryParse("!foo/ ")!; + var pattern = await Assert.That(GitignorePattern.TryParse("!foo/ ")).IsNotNull(); await Assert.That(pattern.Text).IsEqualTo("!foo/ "); await Assert.That(pattern.ToString()).IsEqualTo("!foo/ "); @@ -77,7 +78,7 @@ public async Task TryParse_PreservesOriginalText() [Test] public async Task TryParse_WithUnanchoredPattern_PrependsAnyDepth() { - var pattern = GitignorePattern.TryParse("foo")!; + var pattern = await Assert.That(GitignorePattern.TryParse("foo")).IsNotNull(); await Assert.That(pattern.Segments.Count).IsEqualTo(2); await Assert.That(pattern.Segments[0].IsAnyDepth).IsTrue(); @@ -86,7 +87,7 @@ public async Task TryParse_WithUnanchoredPattern_PrependsAnyDepth() [Test] public async Task TryParse_WithAnchoredPattern_DoesNotPrependAnyDepth() { - var pattern = GitignorePattern.TryParse("/foo")!; + var pattern = await Assert.That(GitignorePattern.TryParse("/foo")).IsNotNull(); await Assert.That(pattern.Segments.Count).IsEqualTo(1); await Assert.That(pattern.Segments[0].IsAnyDepth).IsFalse(); @@ -95,7 +96,7 @@ public async Task TryParse_WithAnchoredPattern_DoesNotPrependAnyDepth() [Test] public async Task TryParse_WithTrailingAnyDepth_AppendsAnyComponentSegment() { - var pattern = GitignorePattern.TryParse("abc/**")!; + var pattern = await Assert.That(GitignorePattern.TryParse("abc/**")).IsNotNull(); // "abc", "**", and the synthesized match-one-component segment. await Assert.That(pattern.Segments.Count).IsEqualTo(3); diff --git a/tests/Buildvana.Core.IO.Tests/UserDirectoryTests.cs b/tests/Buildvana.Core.IO.Tests/UserDirectoryTests.cs index 1aa418a7..85a8fc30 100644 --- a/tests/Buildvana.Core.IO.Tests/UserDirectoryTests.cs +++ b/tests/Buildvana.Core.IO.Tests/UserDirectoryTests.cs @@ -35,10 +35,10 @@ public async Task CreateDirectory_WithFileAtTargetPath_Fails() await File.WriteAllTextAsync(path, string.Empty).ConfigureAwait(false); try { - DirectoryInfo Act() => UserDirectory.CreateDirectory(path); + void Act() => UserDirectory.CreateDirectory(path); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Could not create directory"); + var exception = Assert.Throws(Act); + await Assert.That(exception.Message).Contains("Could not create directory"); await Assert.That(exception.InnerException).IsTypeOf(); } finally @@ -50,6 +50,7 @@ public async Task CreateDirectory_WithFileAtTargetPath_Fails() [Test] public async Task CreateDirectory_WithNullPath_ThrowsRaw() { + // ! Null on purpose: testing null detection static DirectoryInfo Act() => UserDirectory.CreateDirectory(null!); await Assert.That(Act).Throws(); @@ -77,8 +78,8 @@ public async Task Delete_WithNonEmptyDirectory_Fails() { void Act() => UserDirectory.Delete(root); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Could not delete directory"); + var exception = Assert.Throws(Act); + await Assert.That(exception.Message).Contains("Could not delete directory"); await Assert.That(exception.InnerException).IsTypeOf(); } finally @@ -94,8 +95,8 @@ public async Task Delete_WithMissingDirectory_Fails() void Act() => UserDirectory.Delete(path); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Could not delete directory"); + var exception = Assert.Throws(Act); + await Assert.That(exception.Message).Contains("Could not delete directory"); await Assert.That(exception.InnerException).IsTypeOf(); } @@ -114,6 +115,7 @@ public async Task Delete_Recursive_WithNonEmptyDirectory_DeletesTree() [Test] public async Task Delete_WithNullPath_ThrowsRaw() { + // ! Null on purpose: testing null detection static void Act() => UserDirectory.Delete(null!); await Assert.That(Act).Throws(); @@ -164,6 +166,7 @@ public async Task DeleteIfExists_WithoutReporter_DeletesDirectory() [Test] public async Task DeleteIfExists_WithNullPath_ThrowsRaw() { + // ! Null on purpose: testing null detection static void Act() => UserDirectory.DeleteIfExists(null!); await Assert.That(Act).Throws(); @@ -210,6 +213,7 @@ public async Task EnumerateFiles_WithMissingBaseDirectory_ReturnsEmptyList() [Test] public async Task EnumerateFiles_WithNullBaseDirectory_ThrowsRaw() { + // ! Null on purpose: testing null detection static IReadOnlyList Act() => UserDirectory.EnumerateFiles(null!, "*.nupkg"); await Assert.That(Act).Throws(); @@ -218,6 +222,7 @@ public async Task EnumerateFiles_WithNullBaseDirectory_ThrowsRaw() [Test] public async Task EnumerateFiles_WithNullPattern_ThrowsRaw() { + // ! Null on purpose: testing null detection static IReadOnlyList Act() => UserDirectory.EnumerateFiles(Path.GetTempPath(), null!); await Assert.That(Act).Throws(); @@ -234,6 +239,7 @@ public async Task Exists_WithMissingDirectory_ReturnsFalse() [Test] public async Task Exists_WithNullPath_ThrowsRaw() { + // ! Null on purpose: testing null detection static bool Act() => UserDirectory.Exists(null!); await Assert.That(Act).Throws(); @@ -265,14 +271,15 @@ public async Task SetCurrentDirectory_WithMissingDirectory_Fails() void Act() => UserDirectory.SetCurrentDirectory(path); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("the current directory"); + var exception = Assert.Throws(Act); + await Assert.That(exception.Message).Contains("the current directory"); await Assert.That(exception.InnerException).IsTypeOf(); } [Test] public async Task SetCurrentDirectory_WithNullPath_ThrowsRaw() { + // ! Null on purpose: testing null detection static void Act() => UserDirectory.SetCurrentDirectory(null!); await Assert.That(Act).Throws(); diff --git a/tests/Buildvana.Core.IO.Tests/UserFileTests.cs b/tests/Buildvana.Core.IO.Tests/UserFileTests.cs index bfc8ed62..6854e894 100644 --- a/tests/Buildvana.Core.IO.Tests/UserFileTests.cs +++ b/tests/Buildvana.Core.IO.Tests/UserFileTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using System.Text; using Buildvana.Core; using Buildvana.Core.IO; @@ -142,10 +143,10 @@ public Task OpenTextWithEncoding_WithDeniedAccess_Fails() [Test] public async Task ReadAllText_WithMalformedPath_Fails() { - static string Act() => UserFile.ReadAllText("a\0b"); + static void Act() => UserFile.ReadAllText("a\0b"); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Could not read from"); + var exception = Assert.Throws(Act); + await Assert.That(exception.Message).Contains("Could not read from"); await Assert.That(exception.InnerException).IsTypeOf(); } @@ -154,8 +155,8 @@ public async Task WriteAllText_WithMalformedPath_Fails() { static void Act() => UserFile.WriteAllText("a\0b", "x"); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Could not write to"); + var exception = Assert.Throws(Act); + await Assert.That(exception.Message).Contains("Could not write to"); await Assert.That(exception.InnerException).IsTypeOf(); } @@ -164,6 +165,7 @@ public async Task WriteAllText_WithMalformedPath_Fails() [Test] public async Task ReadAllText_WithNullPath_ThrowsRaw() { + // ! Null on purpose: testing null detection static string Act() => UserFile.ReadAllText(null!); await Assert.That(Act).Throws(); @@ -180,6 +182,7 @@ public async Task ReadAllText_WithEmptyPath_ThrowsRaw() [Test] public async Task WriteAllText_WithNullPath_ThrowsRaw() { + // ! Null on purpose: testing null detection static void Act() => UserFile.WriteAllText(null!, "x"); await Assert.That(Act).Throws(); @@ -198,6 +201,7 @@ public async Task WriteAllText_WithEmptyPath_ThrowsRaw() [Test] public async Task ReadAllTextAsync_WithNullPath_ThrowsRawAndSynchronously() { + // ! Null on purpose: testing null detection static void Act() => _ = UserFile.ReadAllTextAsync(null!); await Assert.That(Act).Throws(); @@ -214,6 +218,7 @@ public async Task ReadAllTextAsync_WithEmptyPath_ThrowsRawAndSynchronously() [Test] public async Task WriteAllTextAsync_WithNullPath_ThrowsRawAndSynchronously() { + // ! Null on purpose: testing null detection static void Act() => _ = UserFile.WriteAllTextAsync(null!, "x"); await Assert.That(Act).Throws(); @@ -525,6 +530,7 @@ public async Task Exists_WithDirectoryAtPath_ReturnsFalse() [Test] public async Task Exists_WithNullPath_ThrowsRaw() { + // ! Null on purpose: testing null detection static bool Act() => UserFile.Exists(null!); await Assert.That(Act).Throws(); @@ -540,15 +546,16 @@ public async Task Exists_WithEmptyPath_ThrowsRaw() private static async Task AssertDenied(Action act, string expectedMessagePrefix) { - var exception = await Assert.That(act).Throws(); - await Assert.That(exception!.Message).Contains(expectedMessagePrefix); + var exception = Assert.Throws(act); + await Assert.That(exception.Message).Contains(expectedMessagePrefix); await Assert.That(exception.InnerException).IsTypeOf(); } private static async Task AssertDeniedAsync(Func act, string expectedMessagePrefix) { var exception = await Assert.That(act).Throws(); - await Assert.That(exception!.Message).Contains(expectedMessagePrefix); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + await Assert.That(exception.Message).Contains(expectedMessagePrefix); await Assert.That(exception.InnerException).IsTypeOf(); } } diff --git a/tests/Buildvana.Core.Json.Tests/JsonHelperTests.cs b/tests/Buildvana.Core.Json.Tests/JsonHelperTests.cs index c8a65b13..d63d455f 100644 --- a/tests/Buildvana.Core.Json.Tests/JsonHelperTests.cs +++ b/tests/Buildvana.Core.Json.Tests/JsonHelperTests.cs @@ -16,10 +16,10 @@ internal sealed partial class JsonHelperTests [Test] public async Task LoadObject_WithDeniedAccess_Fails() { - static JsonObject Act() => new JsonHelper().LoadObject(DeniedAccessPath); + static void Act() => new JsonHelper().LoadObject(DeniedAccessPath); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Could not read from"); + var exception = Assert.Throws(Act); + await Assert.That(exception.Message).Contains("Could not read from"); await Assert.That(exception.InnerException).IsTypeOf(); } @@ -28,28 +28,28 @@ public async Task SaveObject_WithDeniedAccess_Fails() { static void Act() => new JsonHelper().SaveObject(new JsonObject(), DeniedAccessPath); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Could not write to"); + var exception = Assert.Throws(Act); + await Assert.That(exception.Message).Contains("Could not write to"); await Assert.That(exception.InnerException).IsTypeOf(); } [Test] public async Task RewriteStringValues_WithDeniedAccess_Fails() { - static bool Act() => new JsonHelper().RewriteStringValues(DeniedAccessPath, (_, _) => null); + static void Act() => new JsonHelper().RewriteStringValues(DeniedAccessPath, (_, _) => null); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Could not read from"); + var exception = Assert.Throws(Act); + await Assert.That(exception.Message).Contains("Could not read from"); await Assert.That(exception.InnerException).IsTypeOf(); } [Test] public async Task InsertProperty_WithDeniedAccess_Fails() { - static bool Act() => new JsonHelper().InsertProperty(DeniedAccessPath, [], "a", JsonValue.Create("x")); + static void Act() => new JsonHelper().InsertProperty(DeniedAccessPath, [], "a", JsonValue.Create("x")); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Could not read from"); + var exception = Assert.Throws(Act); + await Assert.That(exception.Message).Contains("Could not read from"); await Assert.That(exception.InnerException).IsTypeOf(); } @@ -265,7 +265,9 @@ public async Task RewriteBooleanValues_SplicesTheLiteral_LeavingEverythingElseAl """); - var rewritten = new JsonHelper().RewriteBooleanValues(file.Path, static (path, _) => path is ["sdk", "allowPrerelease"] ? true : null); + var rewritten = new JsonHelper().RewriteBooleanValues( + file.Path, + static (path, _) => path is ["sdk", "allowPrerelease"] ? true : null); await Assert.That(rewritten).IsTrue(); await Assert.That(file.ReadText()).IsEqualTo( @@ -314,8 +316,9 @@ public async Task RewriteBooleanValues_OfInvalidJson_Fails() // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning bool Act() => new JsonHelper().RewriteBooleanValues(file.Path, static (_, current) => !current); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("does not contain valid JSON"); + await Assert.That(Act) + .Throws() + .WithMessageContaining("does not contain valid JSON", StringComparison.Ordinal); } [Test] diff --git a/tests/Buildvana.Core.Json.Tests/JsonKeyedObjectConverterTests.cs b/tests/Buildvana.Core.Json.Tests/JsonKeyedObjectConverterTests.cs index a45434c3..1d81b1d6 100644 --- a/tests/Buildvana.Core.Json.Tests/JsonKeyedObjectConverterTests.cs +++ b/tests/Buildvana.Core.Json.Tests/JsonKeyedObjectConverterTests.cs @@ -5,6 +5,7 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Buildvana.Core.Json; +using TUnit.Assertions.Enums; internal sealed class JsonKeyedObjectConverterTests { @@ -44,7 +45,7 @@ public async Task Read_MembersShape_ReadsElementsInDocumentOrder() await Assert.That(list[0].Caption).IsEqualTo("G1"); await Assert.That(list[0].Files).IsEqualTo("src/*.props"); await Assert.That(list[0].Retries).IsEqualTo(3); - await Assert.That(list[0].Tags!.SequenceEqual(["a", "b"])).IsTrue(); + await Assert.That(list[0].Tags).IsEquivalentTo(["a", "b"], CollectionOrdering.Matching); await Assert.That(list[1].Caption).IsEqualTo("G2"); await Assert.That(list[1].Files).IsNull(); } @@ -63,10 +64,10 @@ public async Task ReadWrite_AsProperty_RoundTrips() // where the enclosing parse expects it. const string json = """{"policies":{"a":"one","b":"two"},"name":"n"}"""; var options = CreateOptions(); - var holder = JsonSerializer.Deserialize(json, options); - await Assert.That(holder!.Name).IsEqualTo("n"); - await Assert.That(holder.Policies!.Count).IsEqualTo(2); - await Assert.That(holder.Policies[0]).IsEqualTo(new KeyedValueSample { Pattern = "a", Policy = "one" }); + var holder = await Assert.That(JsonSerializer.Deserialize(json, options)).IsNotNull(); + await Assert.That(holder.Name).IsEqualTo("n"); + await Assert.That(holder.Policies?.Count).IsEqualTo(2); + await Assert.That(holder.Policies?[0]).IsEqualTo(new KeyedValueSample { Pattern = "a", Policy = "one" }); await Assert.That(JsonSerializer.Serialize(holder, options)).IsEqualTo(json); } @@ -84,8 +85,8 @@ public async Task ReadWrite_KeyedListInsideKeyedElement_RoundTrips() const string json = """{"outer":{"policies":{"a":"one","b":"two"}}}"""; var list = Deserialize(json); await Assert.That(list[0].Caption).IsEqualTo("outer"); - await Assert.That(list[0].Policies!.Count).IsEqualTo(2); - await Assert.That(list[0].Policies![1]).IsEqualTo(new KeyedValueSample { Pattern = "b", Policy = "two" }); + await Assert.That(list[0].Policies?.Count).IsEqualTo(2); + await Assert.That(list[0].Policies?[1]).IsEqualTo(new KeyedValueSample { Pattern = "b", Policy = "two" }); await Assert.That(Serialize(list)).IsEqualTo(json); } @@ -108,8 +109,7 @@ public async Task Read_NonObject_Throws() { static IReadOnlyList Act() => Deserialize("""["a"]"""); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Expected a JSON object"); + await Assert.That(Act).Throws().WithMessageContaining("Expected a JSON object", StringComparison.Ordinal); } [Test] @@ -125,10 +125,10 @@ public async Task Read_DuplicateKey_ByDefault_KeepsFirstPositionTakesLastValue() public async Task Read_DuplicateKey_WhenDisallowed_Throws() { var options = CreateOptions(allowDuplicateProperties: false); - object? Act() => JsonSerializer.Deserialize>("""{"a":"one","a":"two"}""", options); + object? Act() + => JsonSerializer.Deserialize>("""{"a":"one","a":"two"}""", options); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Duplicate key 'a'"); + await Assert.That(Act).Throws().WithMessageContaining("Duplicate key 'a'", StringComparison.Ordinal); } [Test] @@ -146,8 +146,7 @@ static string Act() => Serialize([ new() { Pattern = "a", Policy = "two" }, ]); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Duplicate key 'a'"); + await Assert.That(Act).Throws().WithMessageContaining("Duplicate key 'a'", StringComparison.Ordinal); } [Test] @@ -161,7 +160,8 @@ public async Task Read_MembersShape_DuplicateInnerMember_TakesLastValue() public async Task Read_MembersShape_DuplicateInnerMemberWhenDisallowed_Throws() { var options = CreateOptions(allowDuplicateProperties: false); - object? Act() => JsonSerializer.Deserialize>("""{"G1":{"retries":1,"retries":2}}""", options); + object? Act() + => JsonSerializer.Deserialize>("""{"G1":{"retries":1,"retries":2}}""", options); _ = await Assert.That(Act).Throws(); } @@ -171,8 +171,9 @@ public async Task Read_MembersShape_KeyRestatedInsideValue_Throws() { static IReadOnlyList Act() => Deserialize("""{"G1":{"caption":"X"}}"""); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("must not state the key property 'caption'"); + await Assert.That(Act) + .Throws() + .WithMessageContaining("must not state the key property 'caption'", StringComparison.Ordinal); } [Test] @@ -180,8 +181,7 @@ public async Task Read_MembersShape_NonObjectValue_Throws() { static IReadOnlyList Act() => Deserialize("""{"G1":42}"""); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("must be a JSON object"); + await Assert.That(Act).Throws().WithMessageContaining("must be a JSON object", StringComparison.Ordinal); } [Test] @@ -212,15 +212,16 @@ public async Task Write_RenamedKey_ResolvesJsonPropertyName() [Test] public async Task Write_NullKey_Throws() { + // ! Null on purpose: testing null detection static string Act() => Serialize([new() { Pattern = null!, Policy = "x" }]); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("non-null string"); + await Assert.That(Act).Throws().WithMessageContaining("non-null string", StringComparison.Ordinal); } [Test] public async Task Write_ValueShape_NullValue_WritesNull() { + // ! Null on purpose: testing null detection var json = Serialize([new() { Pattern = "a", Policy = null! }]); await Assert.That(json).IsEqualTo("""{"a":null}"""); } @@ -244,9 +245,9 @@ public async Task ReadWrite_SourceGeneratedContext_ResolvesGeneratedNames() }; var list = JsonSerializer.Deserialize>("""{"G1":{"files":"x"}}""", options); - await Assert.That(list!.Count).IsEqualTo(1); - await Assert.That(list[0].Caption).IsEqualTo("G1"); - await Assert.That(list[0].Files).IsEqualTo("x"); + await Assert.That(list?.Count).IsEqualTo(1); + await Assert.That(list?[0].Caption).IsEqualTo("G1"); + await Assert.That(list?[0].Files).IsEqualTo("x"); var json = JsonSerializer.Serialize(list, options); await Assert.That(json).IsEqualTo("""{"G1":{"files":"x","retries":0,"tags":null}}"""); @@ -278,8 +279,8 @@ public async Task ReadWrite_CustomizedContract_FollowsTypeInfoNames() }; var list = JsonSerializer.Deserialize>("""{"G1":{"files":"x"}}""", options); - await Assert.That(list![0].Caption).IsEqualTo("G1"); - await Assert.That(list[0].Files).IsEqualTo("x"); + await Assert.That(list?[0].Caption).IsEqualTo("G1"); + await Assert.That(list?[0].Files).IsEqualTo("x"); var json = JsonSerializer.Serialize(list, options); await Assert.That(json).IsEqualTo("""{"G1":{"files":"x","retries":0,"tags":null}}"""); @@ -290,8 +291,9 @@ public async Task Read_MissingKeyProperty_Throws() { static object Act() => Deserialize("{}"); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("no serializable property 'Nope'"); + await Assert.That(Act) + .Throws() + .WithMessageContaining("no serializable property 'Nope'", StringComparison.Ordinal); } [Test] @@ -299,8 +301,9 @@ public async Task Read_MissingValueProperty_Throws() { static object Act() => Deserialize("{}"); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("no serializable property 'Nope'"); + await Assert.That(Act) + .Throws() + .WithMessageContaining("no serializable property 'Nope'", StringComparison.Ordinal); } [Test] @@ -308,8 +311,9 @@ public async Task Read_NonStringKeyProperty_Throws() { static object Act() => Deserialize("{}"); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("must be of type string"); + await Assert.That(Act) + .Throws() + .WithMessageContaining("must be of type string", StringComparison.Ordinal); } [Test] @@ -317,8 +321,9 @@ public async Task Read_SameKeyAndValueProperty_Throws() { static object Act() => Deserialize("{}"); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("to the same JSON name 'name'"); + await Assert.That(Act) + .Throws() + .WithMessageContaining("to the same JSON name 'name'", StringComparison.Ordinal); } private static JsonSerializerOptions CreateOptions(bool allowDuplicateProperties = true) @@ -329,7 +334,8 @@ private static JsonSerializerOptions CreateOptions(bool allowDuplicateProperties }; private static IReadOnlyList Deserialize(string json) - => JsonSerializer.Deserialize>(json, CreateOptions())!; + => JsonSerializer.Deserialize>(json, CreateOptions()) + ?? throw new InvalidOperationException("The JSON deserializes to null."); private static string Serialize(IReadOnlyList list) => JsonSerializer.Serialize(list, CreateOptions()); diff --git a/tests/Buildvana.Core.Json.Tests/JsonSchemaGeneratorTests.cs b/tests/Buildvana.Core.Json.Tests/JsonSchemaGeneratorTests.cs index 8ce574ac..81d82d8c 100644 --- a/tests/Buildvana.Core.Json.Tests/JsonSchemaGeneratorTests.cs +++ b/tests/Buildvana.Core.Json.Tests/JsonSchemaGeneratorTests.cs @@ -26,48 +26,47 @@ internal sealed class JsonSchemaGeneratorTests public async Task Generate_EmitsDialectAndTitle() { var schema = Generate(); - await Assert.That(schema["$schema"]!.GetValue()) + await Assert.That(schema["$schema"]?.GetValue()) .IsEqualTo("https://json-schema.org/draft/2020-12/schema"); - await Assert.That(schema["title"]!.GetValue()).IsEqualTo("Sample Title"); + await Assert.That(schema["title"]?.GetValue()).IsEqualTo("Sample Title"); } [Test] public async Task Generate_StripsNullFromPlainNullableProperty() { - var type = Generate()["properties"]!["plain"]!["type"]; - await Assert.That(type!.GetValueKind()).IsEqualTo(JsonValueKind.String); + var type = await Assert.That(Generate()["properties"]?["plain"]?["type"]).IsNotNull(); + await Assert.That(type.GetValueKind()).IsEqualTo(JsonValueKind.String); await Assert.That(type.GetValue()).IsEqualTo("string"); } [Test] public async Task Generate_KeepsNullWhenPropertyIsJsonNullable() { - var type = Generate()["properties"]!["maybe"]!["type"]; - await Assert.That(type is JsonArray).IsTrue(); - await Assert.That(((JsonArray)type!).Count).IsEqualTo(2); + var type = await Assert.That(Generate()["properties"]?["maybe"]?["type"]).IsNotNull(); + await Assert.That(type.AsArray().Count).IsEqualTo(2); } [Test] public async Task Generate_SurfacesDescription() { - var description = Generate()["properties"]!["described"]!["description"]; - await Assert.That(description!.GetValue()).IsEqualTo("a described field"); + var description = Generate()["properties"]?["described"]?["description"]; + await Assert.That(description?.GetValue()).IsEqualTo("a described field"); } [Test] public async Task Generate_SurfacesExample() { - var examples = (JsonArray)Generate()["properties"]!["sampled"]!["examples"]!; - await Assert.That(examples.Count).IsEqualTo(1); - await Assert.That(examples[0]!.ToJsonString()).IsEqualTo("""["alpha","beta"]"""); + var examples = await Assert.That(Generate()["properties"]?["sampled"]?["examples"]).IsNotNull(); + await Assert.That(examples.AsArray().Count).IsEqualTo(1); + await Assert.That(examples[0]?.ToJsonString()).IsEqualTo("""["alpha","beta"]"""); } // Both annotations insert at the head of the node, so the one applied last comes first. [Test] public async Task Generate_PutsDescriptionBeforeExample() { - var sampled = (JsonObject)Generate()["properties"]!["sampled"]!; - var firstTwo = sampled.Select(static member => member.Key).Take(2); + var sampled = await Assert.That(Generate()["properties"]?["sampled"]).IsNotNull(); + var firstTwo = sampled.AsObject().Select(static member => member.Key).Take(2); await Assert.That(firstTwo.SequenceEqual(["description", "examples"])).IsTrue(); } @@ -79,20 +78,20 @@ public async Task Generate_ThrowsOnUnparseableExample() [Test] public async Task Generate_ConstrainsDictionaryToAllowedKeys() { - var map = Generate()["properties"]!["map"]!; - await Assert.That(map["additionalProperties"]!.GetValue()).IsFalse(); - await Assert.That(map["properties"]!["alpha"]).IsNotNull(); - await Assert.That(map["properties"]!["beta"]).IsNotNull(); - await Assert.That((map["properties"] as JsonObject)!.Count).IsEqualTo(2); + var map = await Assert.That(Generate()["properties"]?["map"]).IsNotNull(); + await Assert.That(map["additionalProperties"]?.GetValue()).IsFalse(); + await Assert.That(map["properties"]?["alpha"]).IsNotNull(); + await Assert.That(map["properties"]?["beta"]).IsNotNull(); + await Assert.That(map["properties"]?.AsObject().Count).IsEqualTo(2); } [Test] public async Task Generate_ConstrainsStringToAllowedValues() { - var choice = Generate()["properties"]!["choice"]!; - await Assert.That(choice["type"]!.GetValue()).IsEqualTo("string"); - var allowed = ((JsonArray)choice["enum"]!).Select(static value => value!.GetValue()); - await Assert.That(allowed.SequenceEqual(["alpha", "beta"])).IsTrue(); + var choice = await Assert.That(Generate()["properties"]?["choice"]).IsNotNull(); + await Assert.That(choice["type"]?.GetValue()).IsEqualTo("string"); + var allowed = await Assert.That(choice["enum"]).IsNotNull(); + await Assert.That(allowed.AsArray().Select(static value => value?.GetValue()).SequenceEqual(["alpha", "beta"])).IsTrue(); } // An enumerated set is the whole value space, so a member that keeps "null" in its type has to keep null @@ -100,12 +99,12 @@ public async Task Generate_ConstrainsStringToAllowedValues() [Test] public async Task Generate_KeepsNullInAllowedValuesOfANullableMember() { - var choice = Generate()["properties"]!["maybeChoice"]!; - var type = (JsonArray)choice["type"]!; - await Assert.That(type.Select(static t => t!.GetValue()).SequenceEqual(["string", "null"])).IsTrue(); + var choice = await Assert.That(Generate()["properties"]?["maybeChoice"]).IsNotNull(); + var type = await Assert.That(choice["type"]).IsNotNull(); + await Assert.That(type.AsArray().Select(static t => t?.GetValue()).SequenceEqual(["string", "null"])).IsTrue(); - var allowed = (JsonArray)choice["enum"]!; - await Assert.That(allowed.Count).IsEqualTo(3); + var allowed = await Assert.That(choice["enum"]).IsNotNull(); + await Assert.That(allowed.AsArray().Count).IsEqualTo(3); await Assert.That(allowed[2]).IsNull(); } @@ -114,46 +113,44 @@ public async Task Generate_KeepsNullInAllowedValuesOfANullableMember() [Test] public async Task Generate_AllowedValuesReplaceNonBlankConstraints() { - var properties = Generate()["properties"]!; - var enumerated = properties["requiredChoice"]!; + var properties = await Assert.That(Generate()["properties"]).IsNotNull(); + var enumerated = await Assert.That(properties["requiredChoice"]).IsNotNull(); await Assert.That(enumerated["enum"]).IsNotNull(); await Assert.That(enumerated["minLength"]).IsNull(); await Assert.That(enumerated["pattern"]).IsNull(); - var open = properties["requiredText"]!; - await Assert.That(open["minLength"]!.GetValue()).IsEqualTo(1); - await Assert.That(open["pattern"]!.GetValue()).IsEqualTo(@"\S"); + var open = await Assert.That(properties["requiredText"]).IsNotNull(); + await Assert.That(open["minLength"]?.GetValue()).IsEqualTo(1); + await Assert.That(open["pattern"]?.GetValue()).IsEqualTo(@"\S"); } [Test] public async Task Generate_KeepsNullOnNullableDictionaryValue() { - var type = Generate()["properties"]!["env"]!["additionalProperties"]!["type"]; - await Assert.That(type is JsonArray).IsTrue(); - await Assert.That(((JsonArray)type!).Count).IsEqualTo(2); + var type = await Assert.That(Generate()["properties"]?["env"]?["additionalProperties"]?["type"]).IsNotNull(); + await Assert.That(type.AsArray().Count).IsEqualTo(2); } [Test] public async Task Generate_StripsNullFromNonNullableDictionaryValue() { - var type = Generate()["properties"]!["vars"]!["additionalProperties"]!["type"]; - await Assert.That(type!.GetValueKind()).IsEqualTo(JsonValueKind.String); + var type = await Assert.That(Generate()["properties"]?["vars"]?["additionalProperties"]?["type"]).IsNotNull(); + await Assert.That(type.GetValueKind()).IsEqualTo(JsonValueKind.String); await Assert.That(type.GetValue()).IsEqualTo("string"); } [Test] public async Task Generate_KeepsNullOnNullableArrayElement() { - var type = Generate()["properties"]!["items"]!["items"]!["type"]; - await Assert.That(type is JsonArray).IsTrue(); - await Assert.That(((JsonArray)type!).Count).IsEqualTo(2); + var type = await Assert.That(Generate()["properties"]?["items"]?["items"]?["type"]).IsNotNull(); + await Assert.That(type.AsArray().Count).IsEqualTo(2); } [Test] public async Task Generate_StripsNullFromNonNullableArrayElement() { - var type = Generate()["properties"]!["tags"]!["items"]!["type"]; - await Assert.That(type!.GetValueKind()).IsEqualTo(JsonValueKind.String); + var type = await Assert.That(Generate()["properties"]?["tags"]?["items"]?["type"]).IsNotNull(); + await Assert.That(type.GetValueKind()).IsEqualTo(JsonValueKind.String); await Assert.That(type.GetValue()).IsEqualTo("string"); } @@ -161,16 +158,17 @@ public async Task Generate_StripsNullFromNonNullableArrayElement() public async Task Generate_WithoutDefaults_EmitsNoDefaults() { var schema = JsonSchemaGenerator.Generate(Options); - await Assert.That(schema["properties"]!["text"]!["default"]).IsNull(); + var text = await Assert.That(schema["properties"]?["text"]).IsNotNull(); + await Assert.That(text["default"]).IsNull(); } // The schema comes from one type, the defaults from another: matching is by JSON name alone. [Test] public async Task Generate_WithDefaults_AnnotatesLeafProperties() { - var properties = GenerateWithDefaults()["properties"]!; - await Assert.That(properties["text"]!["default"]!.GetValue()).IsEqualTo("hello"); - await Assert.That(properties["flag"]!["default"]!.GetValue()).IsTrue(); + var properties = await Assert.That(GenerateWithDefaults()["properties"]).IsNotNull(); + await Assert.That(properties["text"]?["default"]?.GetValue()).IsEqualTo("hello"); + await Assert.That(properties["flag"]?["default"]?.GetValue()).IsTrue(); } // The enum default renders exactly as the serializer options render the value ("two", not "Two" or 1), @@ -178,21 +176,21 @@ public async Task Generate_WithDefaults_AnnotatesLeafProperties() [Test] public async Task Generate_WithDefaults_RendersEnumDefaultsThroughOptions() { - var level = GenerateWithDefaults()["properties"]!["level"]!; - await Assert.That(level["default"]!.GetValue()).IsEqualTo("two"); + var level = await Assert.That(GenerateWithDefaults()["properties"]?["level"]).IsNotNull(); + await Assert.That(level["default"]?.GetValue()).IsEqualTo("two"); } [Test] public async Task Generate_WithDefaults_StatesNoDefaultForNullValues() { - var notStated = GenerateWithDefaults()["properties"]!["notStated"]!; + var notStated = await Assert.That(GenerateWithDefaults()["properties"]?["notStated"]).IsNotNull(); await Assert.That(notStated["default"]).IsNull(); } [Test] public async Task Generate_WithDefaults_HonorsNoDefaultAttribute() { - var dynamic = GenerateWithDefaults()["properties"]!["dynamic"]!; + var dynamic = await Assert.That(GenerateWithDefaults()["properties"]?["dynamic"]).IsNotNull(); await Assert.That(dynamic["default"]).IsNull(); } @@ -209,26 +207,26 @@ public async Task Generate_WithDefaults_ThrowsOnUnmatchedSchemaProperty() [Test] public async Task Generate_WithDefaults_StatesNoDefaultForCollections() { - var tags = GenerateWithDefaults()["properties"]!["tags"]!; + var tags = await Assert.That(GenerateWithDefaults()["properties"]?["tags"]).IsNotNull(); await Assert.That(tags["default"]).IsNull(); } [Test] public async Task Generate_WithDefaults_RecursesIntoSections() { - var section = GenerateWithDefaults()["properties"]!["section"]!; + var section = await Assert.That(GenerateWithDefaults()["properties"]?["section"]).IsNotNull(); await Assert.That(section["default"]).IsNull(); - await Assert.That(section["properties"]!["inner"]!["default"]!.GetValue()).IsEqualTo("nested-default"); + await Assert.That(section["properties"]?["inner"]?["default"]?.GetValue()).IsEqualTo("nested-default"); } [Test] public async Task Generate_EmitsRequiredForRequiredMembers() { var schema = JsonSchemaGenerator.Generate(Options); - var required = (JsonArray)schema["required"]!; - await Assert.That(required.Count).IsEqualTo(2); - await Assert.That(required[0]!.GetValue()).IsEqualTo("must"); - await Assert.That(required[1]!.GetValue()).IsEqualTo("mustFlag"); + var required = await Assert.That(schema["required"]).IsNotNull(); + await Assert.That(required.AsArray().Count).IsEqualTo(2); + await Assert.That(required[0]?.GetValue()).IsEqualTo("must"); + await Assert.That(required[1]?.GetValue()).IsEqualTo("mustFlag"); } // `required` only checks presence, so required strings are additionally constrained to non-blank @@ -236,15 +234,15 @@ public async Task Generate_EmitsRequiredForRequiredMembers() [Test] public async Task Generate_ConstrainsRequiredStringsToNonBlank() { - var must = JsonSchemaGenerator.Generate(Options)["properties"]!["must"]!; - await Assert.That(must["minLength"]!.GetValue()).IsEqualTo(1); - await Assert.That(must["pattern"]!.GetValue()).IsEqualTo(@"\S"); + var must = await Assert.That(JsonSchemaGenerator.Generate(Options)["properties"]?["must"]).IsNotNull(); + await Assert.That(must["minLength"]?.GetValue()).IsEqualTo(1); + await Assert.That(must["pattern"]?.GetValue()).IsEqualTo(@"\S"); } [Test] public async Task Generate_LeavesOptionalStringsUnconstrained() { - var may = JsonSchemaGenerator.Generate(Options)["properties"]!["may"]!; + var may = await Assert.That(JsonSchemaGenerator.Generate(Options)["properties"]?["may"]).IsNotNull(); await Assert.That(may["minLength"]).IsNull(); await Assert.That(may["pattern"]).IsNull(); } @@ -252,7 +250,7 @@ public async Task Generate_LeavesOptionalStringsUnconstrained() [Test] public async Task Generate_LeavesRequiredNonStringsUnconstrained() { - var mustFlag = JsonSchemaGenerator.Generate(Options)["properties"]!["mustFlag"]!; + var mustFlag = await Assert.That(JsonSchemaGenerator.Generate(Options)["properties"]?["mustFlag"]).IsNotNull(); await Assert.That(mustFlag["minLength"]).IsNull(); await Assert.That(mustFlag["pattern"]).IsNull(); } @@ -260,25 +258,25 @@ public async Task Generate_LeavesRequiredNonStringsUnconstrained() [Test] public async Task Generate_RendersValueShapeKeyedListAsObject() { - var policies = GenerateKeyed()["properties"]!["policies"]!; - await Assert.That(policies["type"]!.GetValue()).IsEqualTo("object"); + var policies = await Assert.That(GenerateKeyed()["properties"]?["policies"]).IsNotNull(); + await Assert.That(policies["type"]?.GetValue()).IsEqualTo("object"); // The value property's schema, required-string constraints included, becomes additionalProperties. - var values = policies["additionalProperties"]!; - await Assert.That(values["type"]!.GetValue()).IsEqualTo("string"); - await Assert.That(values["minLength"]!.GetValue()).IsEqualTo(1); + var values = await Assert.That(policies["additionalProperties"]).IsNotNull(); + await Assert.That(values["type"]?.GetValue()).IsEqualTo("string"); + await Assert.That(values["minLength"]?.GetValue()).IsEqualTo(1); } [Test] public async Task Generate_RendersRemainingMembersKeyedListAsObject() { - var groups = GenerateKeyed()["properties"]!["groups"]!; - await Assert.That(groups["type"]!.GetValue()).IsEqualTo("object"); + var groups = await Assert.That(GenerateKeyed()["properties"]?["groups"]).IsNotNull(); + await Assert.That(groups["type"]?.GetValue()).IsEqualTo("object"); - var element = groups["additionalProperties"]!; - await Assert.That(element["type"]!.GetValue()).IsEqualTo("object"); - await Assert.That(element["properties"]!["files"]).IsNotNull(); - await Assert.That(element["properties"]!["retries"]).IsNotNull(); + var element = await Assert.That(groups["additionalProperties"]).IsNotNull(); + await Assert.That(element["type"]?.GetValue()).IsEqualTo("object"); + await Assert.That(element["properties"]?["files"]).IsNotNull(); + await Assert.That(element["properties"]?["retries"]).IsNotNull(); } // The key travels as the JSON property name: the converter refuses an element value that restates it, @@ -286,8 +284,8 @@ public async Task Generate_RendersRemainingMembersKeyedListAsObject() [Test] public async Task Generate_ForbidsKeyInsideRemainingMembers() { - var element = GenerateKeyed()["properties"]!["groups"]!["additionalProperties"]!; - await Assert.That(element["properties"]!["caption"]!.GetValue()).IsFalse(); + var element = await Assert.That(GenerateKeyed()["properties"]?["groups"]?["additionalProperties"]).IsNotNull(); + await Assert.That(element["properties"]?["caption"]?.GetValue()).IsFalse(); } // The key travels as the member name, where a property's own constraints cannot reach it, so a required @@ -297,13 +295,13 @@ public async Task Generate_ConstrainsRequiredKeyThroughPropertyNames() { var schema = GenerateKeyed(); - var valueShape = schema["properties"]!["policies"]!["propertyNames"]!; - await Assert.That(valueShape["minLength"]!.GetValue()).IsEqualTo(1); - await Assert.That(valueShape["pattern"]!.GetValue()).IsEqualTo(@"\S"); + var valueShape = await Assert.That(schema["properties"]?["policies"]?["propertyNames"]).IsNotNull(); + await Assert.That(valueShape["minLength"]?.GetValue()).IsEqualTo(1); + await Assert.That(valueShape["pattern"]?.GetValue()).IsEqualTo(@"\S"); - var remainingMembersShape = schema["properties"]!["groups"]!["propertyNames"]!; - await Assert.That(remainingMembersShape["minLength"]!.GetValue()).IsEqualTo(1); - await Assert.That(remainingMembersShape["pattern"]!.GetValue()).IsEqualTo(@"\S"); + var remainingMembersShape = await Assert.That(schema["properties"]?["groups"]?["propertyNames"]).IsNotNull(); + await Assert.That(remainingMembersShape["minLength"]?.GetValue()).IsEqualTo(1); + await Assert.That(remainingMembersShape["pattern"]?.GetValue()).IsEqualTo(@"\S"); } // A key the model does not require states nothing about its own value, member name included. The node @@ -312,9 +310,9 @@ public async Task Generate_ConstrainsRequiredKeyThroughPropertyNames() [Test] public async Task Generate_LeavesAnOptionalKeyUnconstrained() { - var propertyNames = (JsonObject)GenerateKeyed()["properties"]!["optionalKeys"]!["propertyNames"]!; + var propertyNames = await Assert.That(GenerateKeyed()["properties"]?["optionalKeys"]?["propertyNames"]).IsNotNull(); - await Assert.That(propertyNames.Count).IsEqualTo(0); + await Assert.That(propertyNames.AsObject().Count).IsEqualTo(0); } // An example on the key is an example of a member name, so it lands in propertyNames; one on the value @@ -322,13 +320,13 @@ public async Task Generate_LeavesAnOptionalKeyUnconstrained() [Test] public async Task Generate_RoutesKeyAndValueExamplesToTheirOwnNodes() { - var exemplified = GenerateKeyed()["properties"]!["exemplified"]!; + var exemplified = await Assert.That(GenerateKeyed()["properties"]?["exemplified"]).IsNotNull(); - var keyExamples = (JsonArray)exemplified["propertyNames"]!["examples"]!; - await Assert.That(keyExamples[0]!.GetValue()).IsEqualTo("Some.Package.*"); + var keyExamples = await Assert.That(exemplified["propertyNames"]?["examples"]).IsNotNull(); + await Assert.That(keyExamples[0]?.GetValue()).IsEqualTo("Some.Package.*"); - var valueExamples = (JsonArray)exemplified["additionalProperties"]!["examples"]!; - await Assert.That(valueExamples[0]!.GetValue()).IsEqualTo("patch"); + var valueExamples = await Assert.That(exemplified["additionalProperties"]?["examples"]).IsNotNull(); + await Assert.That(valueExamples[0]?.GetValue()).IsEqualTo("patch"); } // A description of the key describes a member name, so it travels where the example does. Both exits @@ -339,14 +337,14 @@ public async Task Generate_RoutesKeyAndValueDescriptionsToTheirOwnNodes() { var schema = GenerateKeyed(); - var exemplified = schema["properties"]!["exemplified"]!; - await Assert.That(exemplified["propertyNames"]!["description"]!.GetValue()) + var exemplified = await Assert.That(schema["properties"]?["exemplified"]).IsNotNull(); + await Assert.That(exemplified["propertyNames"]?["description"]?.GetValue()) .IsEqualTo("Pattern over package ids."); - await Assert.That(exemplified["additionalProperties"]!["description"]!.GetValue()) + await Assert.That(exemplified["additionalProperties"]?["description"]?.GetValue()) .IsEqualTo("Policy governing every matching pin."); - var optionalKeys = schema["properties"]!["exemplifiedOptionalKeys"]!; - await Assert.That(optionalKeys["propertyNames"]!["description"]!.GetValue()) + var optionalKeys = await Assert.That(schema["properties"]?["exemplifiedOptionalKeys"]).IsNotNull(); + await Assert.That(optionalKeys["propertyNames"]?["description"]?.GetValue()) .IsEqualTo("Caption naming the group."); } @@ -355,8 +353,8 @@ await Assert.That(optionalKeys["propertyNames"]!["description"]!.GetValue()) + var propertyNames = await Assert.That(GenerateKeyed()["properties"]?["exemplifiedOptionalKeys"]?["propertyNames"]).IsNotNull(); + await Assert.That(propertyNames["examples"]?[0]?.GetValue()) .IsEqualTo("SDK package injections"); await Assert.That(propertyNames["minLength"]).IsNull(); } @@ -365,39 +363,38 @@ await Assert.That(((JsonArray)propertyNames["examples"]!)[0]!.GetValue() public async Task Generate_PrunesKeyFromRequiredAndKeepsTheRest() { var schema = GenerateKeyed(); - var groups = schema["properties"]!["groups"]!["additionalProperties"]!; + var groups = await Assert.That(schema["properties"]?["groups"]?["additionalProperties"]).IsNotNull(); await Assert.That(groups["required"]).IsNull(); - var requiredGroups = schema["properties"]!["requiredGroups"]!["additionalProperties"]!; - var required = (JsonArray)requiredGroups["required"]!; - await Assert.That(required.Count).IsEqualTo(1); - await Assert.That(required[0]!.GetValue()).IsEqualTo("files"); + var requiredGroups = await Assert.That(schema["properties"]?["requiredGroups"]?["additionalProperties"]).IsNotNull(); + var required = await Assert.That(requiredGroups["required"]).IsNotNull(); + await Assert.That(required.AsArray().Count).IsEqualTo(1); + await Assert.That(required[0]?.GetValue()).IsEqualTo("files"); } [Test] public async Task Generate_RendersNestedKeyedList() { - var policies = GenerateKeyed()["properties"]!["nested"]!["additionalProperties"]!["properties"]!["policies"]!; - await Assert.That(policies["type"]!.GetValue()).IsEqualTo("object"); - await Assert.That(policies["additionalProperties"]!["type"]!.GetValue()).IsEqualTo("string"); + var policies = await Assert.That(GenerateKeyed()["properties"]?["nested"]?["additionalProperties"]?["properties"]?["policies"]) + .IsNotNull(); + await Assert.That(policies["type"]?.GetValue()).IsEqualTo("object"); + await Assert.That(policies["additionalProperties"]?["type"]?.GetValue()).IsEqualTo("string"); } [Test] public async Task Generate_KeepsDescriptionOnKeyedList() { - var description = GenerateKeyed()["properties"]!["policies"]!["description"]; - await Assert.That(description!.GetValue()).IsEqualTo("the policies"); + var description = GenerateKeyed()["properties"]?["policies"]?["description"]; + await Assert.That(description?.GetValue()).IsEqualTo("the policies"); } [Test] public async Task Generate_KeepsNullOnJsonNullableKeyedList() { - var type = GenerateKeyed()["properties"]!["maybePolicies"]!["type"]; - await Assert.That(type is JsonArray).IsTrue(); - var typeArray = (JsonArray)type!; - await Assert.That(typeArray.Count).IsEqualTo(2); - await Assert.That(typeArray[0]!.GetValue()).IsEqualTo("object"); - await Assert.That(typeArray[1]!.GetValue()).IsEqualTo("null"); + var type = await Assert.That(GenerateKeyed()["properties"]?["maybePolicies"]?["type"]).IsNotNull(); + await Assert.That(type.AsArray().Count).IsEqualTo(2); + await Assert.That(type[0]?.GetValue()).IsEqualTo("object"); + await Assert.That(type[1]?.GetValue()).IsEqualTo("null"); } // A keyed-object list is dictionary-valued in JSON: [JsonAllowedKeys] closes its key set the same way it @@ -405,20 +402,20 @@ public async Task Generate_KeepsNullOnJsonNullableKeyedList() [Test] public async Task Generate_ConstrainsKeyedListToAllowedKeys() { - var limited = GenerateKeyed()["properties"]!["limitedPolicies"]!; - await Assert.That(limited["additionalProperties"]!.GetValue()).IsFalse(); - var properties = (JsonObject)limited["properties"]!; - await Assert.That(properties.Count).IsEqualTo(2); - await Assert.That(properties["first"]!["type"]!.GetValue()).IsEqualTo("string"); - await Assert.That(properties["second"]!["type"]!.GetValue()).IsEqualTo("string"); + var limited = await Assert.That(GenerateKeyed()["properties"]?["limitedPolicies"]).IsNotNull(); + await Assert.That(limited["additionalProperties"]?.GetValue()).IsFalse(); + var properties = await Assert.That(limited["properties"]).IsNotNull(); + await Assert.That(properties.AsObject().Count).IsEqualTo(2); + await Assert.That(properties["first"]?["type"]?.GetValue()).IsEqualTo("string"); + await Assert.That(properties["second"]?["type"]?.GetValue()).IsEqualTo("string"); } [Test] public async Task Generate_RendersKeyedListAtRoot() { var schema = JsonSchemaGenerator.Generate>(Options); - await Assert.That(schema["type"]!.GetValue()).IsEqualTo("object"); - await Assert.That(schema["additionalProperties"]!["type"]!.GetValue()).IsEqualTo("string"); + await Assert.That(schema["type"]?.GetValue()).IsEqualTo("object"); + await Assert.That(schema["additionalProperties"]?["type"]?.GetValue()).IsEqualTo("string"); } // Without JsonKeyedObjectConverter in the options, the list deserializes as a plain JSON array, and the @@ -427,18 +424,19 @@ public async Task Generate_RendersKeyedListAtRoot() public async Task Generate_WithoutKeyedConverter_RendersListAsArray() { var schema = JsonSchemaGenerator.Generate(KeyedConverterlessOptions); - var policies = schema["properties"]!["policies"]!; - await Assert.That(policies["type"]!.GetValue()).IsEqualTo("array"); - await Assert.That(policies["items"]!["type"]!.GetValue()).IsEqualTo("object"); + var policies = await Assert.That(schema["properties"]?["policies"]).IsNotNull(); + await Assert.That(policies["type"]?.GetValue()).IsEqualTo("array"); + await Assert.That(policies["items"]?["type"]?.GetValue()).IsEqualTo("object"); } // Object-shaped in the schema, but still a collection: no default, and no recursion into it. [Test] public async Task Generate_WithDefaults_StatesNoDefaultForKeyedObjectLists() { - var policies = GenerateWithDefaults()["properties"]!["policies"]!; + var policies = await Assert.That(GenerateWithDefaults()["properties"]?["policies"]).IsNotNull(); await Assert.That(policies["default"]).IsNull(); - await Assert.That(policies["additionalProperties"]!["default"]).IsNull(); + var values = await Assert.That(policies["additionalProperties"]).IsNotNull(); + await Assert.That(values["default"]).IsNull(); } [Test] diff --git a/tests/Buildvana.Core.Json.Tests/JsonSchemaValidatorTests.cs b/tests/Buildvana.Core.Json.Tests/JsonSchemaValidatorTests.cs index 50be0bef..9289157f 100644 --- a/tests/Buildvana.Core.Json.Tests/JsonSchemaValidatorTests.cs +++ b/tests/Buildvana.Core.Json.Tests/JsonSchemaValidatorTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; @@ -206,6 +207,7 @@ public async Task Validate_WithSourceMap_FillsLineAndColumn() await Assert.That(errors[0].Column).IsEqualTo(11); } + // ! Null on purpose: testing null detection [Test] public async Task Validate_WithNullSourceMap_Throws() => await Assert.That(() => JsonSchemaValidator.Validate( @@ -280,7 +282,12 @@ public async Task Validate_ConstrainedKeyedList_ReportsDisallowedKey() await Assert.That(errors[0].JsonPointer).IsEqualTo("/limitedPolicies/third"); } - private static JsonNode Schema(string json) => JsonNode.Parse(json)!; + private static JsonNode Schema(string json) + { + var schema = JsonNode.Parse(json); + Debug.Assert(schema is not null, "Parse returns null only for the null literal, and no test passes it as a schema."); + return schema; + } private static JsonNode KeyedSchema() => JsonSchemaGenerator.Generate(KeyedOptions); diff --git a/tests/Buildvana.Core.Process.Tests/ProcessRunnerTests.cs b/tests/Buildvana.Core.Process.Tests/ProcessRunnerTests.cs index 066fd512..c06b2c2f 100644 --- a/tests/Buildvana.Core.Process.Tests/ProcessRunnerTests.cs +++ b/tests/Buildvana.Core.Process.Tests/ProcessRunnerTests.cs @@ -124,8 +124,9 @@ public async Task Run_WhenTheChildFails_StatesThatAnExternalProgramFailed() var exception = await Assert.That(async () => await runner.RunAsync(executable, args).ConfigureAwait(false)) .Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.ExternalProgramFailed); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.ExternalProgramFailed); await Assert.That(exception.Message).Contains("exited with code 9"); } @@ -134,10 +135,9 @@ public async Task RunWithInheritedStdio_WithMissingExecutable_Fails() { var runner = new ProcessRunner(); - var exception = await Assert.That(() => runner.RunWithInheritedStdioAsync("bv-test-no-such-executable", [])) - .Throws(); - - await Assert.That(exception!.Message).Contains("bv-test-no-such-executable"); + await Assert.That(() => runner.RunWithInheritedStdioAsync("bv-test-no-such-executable", [])) + .Throws() + .WithMessageContaining("bv-test-no-such-executable", StringComparison.Ordinal); } [Test] diff --git a/tests/Buildvana.Core.Versioning.Tests/VersionCalculatorTests.cs b/tests/Buildvana.Core.Versioning.Tests/VersionCalculatorTests.cs index 164bf13a..70efcdf1 100644 --- a/tests/Buildvana.Core.Versioning.Tests/VersionCalculatorTests.cs +++ b/tests/Buildvana.Core.Versioning.Tests/VersionCalculatorTests.cs @@ -14,9 +14,8 @@ public async Task Calculate_MissingVersionFile_Throws() { using var repo = new TempGitRepo(); repo.CommitAll(); - var exception = CatchCalculate(repo, new BuildvanaConfig()); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Message).Contains("VERSION"); + var exception = await Assert.That(CatchCalculate(repo, new BuildvanaConfig())).IsNotNull(); + await Assert.That(exception.Message).Contains("VERSION"); } [Test] @@ -25,9 +24,8 @@ public async Task Calculate_MalformedVersionFile_Throws() using var repo = new TempGitRepo(); repo.WriteFile("VERSION", "banana\n"); repo.CommitAll(); - var exception = CatchCalculate(repo, new BuildvanaConfig()); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Message).Contains("banana"); + var exception = await Assert.That(CatchCalculate(repo, new BuildvanaConfig())).IsNotNull(); + await Assert.That(exception.Message).Contains("banana"); } [Test] @@ -36,9 +34,8 @@ public async Task Calculate_PrereleaseWithoutTag_Throws() using var repo = new TempGitRepo(); repo.WriteFile("VERSION", "1.0-\n"); repo.CommitAll(); - var exception = CatchCalculate(repo, new BuildvanaConfig()); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Message).Contains("versioning.prereleaseTag"); + var exception = await Assert.That(CatchCalculate(repo, new BuildvanaConfig())).IsNotNull(); + await Assert.That(exception.Message).Contains("versioning.prereleaseTag"); } [Test] @@ -48,9 +45,8 @@ public async Task Calculate_InvalidPrereleaseTag_Throws() repo.WriteFile("VERSION", "1.0-\n"); repo.CommitAll(); var config = new BuildvanaConfig { Versioning = new VersioningConfig { PrereleaseTag = "not valid" } }; - var exception = CatchCalculate(repo, config); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Message).Contains("not valid"); + var exception = await Assert.That(CatchCalculate(repo, config)).IsNotNull(); + await Assert.That(exception.Message).Contains("not valid"); } [Test] diff --git a/tests/Buildvana.Core.Versioning.Tests/VersionFileTests.cs b/tests/Buildvana.Core.Versioning.Tests/VersionFileTests.cs index 7c7e36e3..c57e04b5 100644 --- a/tests/Buildvana.Core.Versioning.Tests/VersionFileTests.cs +++ b/tests/Buildvana.Core.Versioning.Tests/VersionFileTests.cs @@ -11,9 +11,8 @@ internal sealed class VersionFileTests public async Task Load_MissingFile_Throws() { using var home = new TempHome(); - var exception = CatchLoad(home); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Message).Contains("not found"); + var exception = await Assert.That(CatchLoad(home)).IsNotNull(); + await Assert.That(exception.Message).Contains("not found"); } [Test] @@ -21,9 +20,8 @@ public async Task Load_MalformedFile_Throws() { using var home = new TempHome(); home.WriteFile(VersionFile.FileName, "banana\n"); - var exception = CatchLoad(home); - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Message).Contains("banana"); + var exception = await Assert.That(CatchLoad(home)).IsNotNull(); + await Assert.That(exception.Message).Contains("banana"); } [Test] diff --git a/tests/Buildvana.Core.Versioning.Tests/VersionSpecParseTests.cs b/tests/Buildvana.Core.Versioning.Tests/VersionSpecParseTests.cs index 2388ca4e..f9c95563 100644 --- a/tests/Buildvana.Core.Versioning.Tests/VersionSpecParseTests.cs +++ b/tests/Buildvana.Core.Versioning.Tests/VersionSpecParseTests.cs @@ -54,18 +54,9 @@ public async Task Parse_ValidInput_Parses() [Test] public async Task Parse_InvalidInput_ThrowsBuildFailedException() { - BuildFailedException? exception = null; - try - { - _ = VersionSpec.Parse("banana"); - } - catch (BuildFailedException e) - { - exception = e; - } - - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Message).Contains("banana"); + await Assert.That(() => VersionSpec.Parse("banana")) + .Throws() + .WithMessageContaining("banana", StringComparison.Ordinal); } [Test] diff --git a/tests/Buildvana.Core.Versioning.Tests/VersioningSettingsTests.cs b/tests/Buildvana.Core.Versioning.Tests/VersioningSettingsTests.cs index 98830dce..17435a6a 100644 --- a/tests/Buildvana.Core.Versioning.Tests/VersioningSettingsTests.cs +++ b/tests/Buildvana.Core.Versioning.Tests/VersioningSettingsTests.cs @@ -72,17 +72,8 @@ public async Task IsPublicReleaseBranch_InvalidPattern_ThrowsBuildFailedExceptio { var config = new BuildvanaConfig { Release = new ReleaseConfig { Branches = ["("] } }; var settings = new VersioningSettings(config); - BuildFailedException? exception = null; - try - { - _ = settings.IsPublicReleaseBranch("main"); - } - catch (BuildFailedException e) - { - exception = e; - } - - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Message).Contains("release.branches"); + await Assert.That(() => settings.IsPublicReleaseBranch("main")) + .Throws() + .WithMessageContaining("release.branches", StringComparison.Ordinal); } } diff --git a/tests/Buildvana.Runtime.Tests/PostReleaseHookArgsTests.cs b/tests/Buildvana.Runtime.Tests/PostReleaseHookArgsTests.cs index 5abb3f64..c78d82bc 100644 --- a/tests/Buildvana.Runtime.Tests/PostReleaseHookArgsTests.cs +++ b/tests/Buildvana.Runtime.Tests/PostReleaseHookArgsTests.cs @@ -36,7 +36,7 @@ public async Task Load_RoundTripsWhatBvWrites(bool withConfigFile) var written = SampleArgs(dir, withConfigFile); var relativePath = WellKnownPaths.GetHookArgsFile(PostReleaseHookArgs.Context, PostReleaseHookArgs.Event); var path = Path.Combine(dir, relativePath); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + _ = Directory.CreateDirectory(await Assert.That(Path.GetDirectoryName(path)).IsNotNull()); object boxed = written; var json = JsonSerializer.Serialize(boxed, boxed.GetType(), BuildvanaJsonContext.Default); await File.WriteAllTextAsync(path, json).ConfigureAwait(false); @@ -75,7 +75,7 @@ public async Task Load_WithoutHomeDirectory_ReadsTheArgsFileOfTheCurrentDirector { var relativePath = WellKnownPaths.GetHookArgsFile(PostReleaseHookArgs.Context, PostReleaseHookArgs.Event); var path = Path.Combine(dir, relativePath); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + _ = Directory.CreateDirectory(await Assert.That(Path.GetDirectoryName(path)).IsNotNull()); object boxed = SampleArgs(dir); var json = JsonSerializer.Serialize(boxed, boxed.GetType(), BuildvanaJsonContext.Default); await File.WriteAllTextAsync(path, json).ConfigureAwait(false); @@ -100,7 +100,7 @@ public async Task Load_InvalidJson_Throws() try { var path = Path.Combine(dir, WellKnownPaths.GetHookArgsFile("release", "post-release")); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + _ = Directory.CreateDirectory(await Assert.That(Path.GetDirectoryName(path)).IsNotNull()); await File.WriteAllTextAsync(path, "{ not json ").ConfigureAwait(false); await Assert.That(() => PostReleaseHookArgs.Load(dir)).Throws(); } @@ -120,14 +120,15 @@ public async Task Load_UnreadableArgsFile_Throws() try { var path = Path.Combine(dir, WellKnownPaths.GetHookArgsFile("release", "post-release")); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + _ = Directory.CreateDirectory(await Assert.That(Path.GetDirectoryName(path)).IsNotNull()); await File.WriteAllTextAsync(path, "{}").ConfigureAwait(false); await using var locker = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None).ConfigureAwait(false); PostReleaseHookArgs Act() => PostReleaseHookArgs.Load(dir); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Could not read from"); + await Assert.That(Act) + .Throws() + .WithMessageContaining("Could not read from", StringComparison.Ordinal); } finally { diff --git a/tests/Buildvana.Sdk.SourceGenerators.Tests/TestCompilation.cs b/tests/Buildvana.Sdk.SourceGenerators.Tests/TestCompilation.cs index 58cd99b7..742ada34 100644 --- a/tests/Buildvana.Sdk.SourceGenerators.Tests/TestCompilation.cs +++ b/tests/Buildvana.Sdk.SourceGenerators.Tests/TestCompilation.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -26,7 +27,9 @@ public static async Task AssertHasNoErrors(Compilation compilation) private static IEnumerable GetReferences() { - var trustedAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + var trustedAssemblyList = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; + Debug.Assert(trustedAssemblyList is not null, "The .NET host lists the trusted platform assemblies of every process."); + var trustedAssemblies = trustedAssemblyList.Split(Path.PathSeparator); return trustedAssemblies .Where(static p => Path.GetFileName(p) is "System.Runtime.dll" or "System.Private.CoreLib.dll" or "netstandard.dll") .Select(MetadataReference (p) => MetadataReference.CreateFromFile(p)); diff --git a/tests/Buildvana.Sdk.Tasks.Tests/ComputeVersionTests.cs b/tests/Buildvana.Sdk.Tasks.Tests/ComputeVersionTests.cs index 19f80255..c24d5a3e 100644 --- a/tests/Buildvana.Sdk.Tasks.Tests/ComputeVersionTests.cs +++ b/tests/Buildvana.Sdk.Tasks.Tests/ComputeVersionTests.cs @@ -13,7 +13,7 @@ public async Task Execute_WithMissingHomeDirectory_LogsError() var task = new ComputeVersion { BuildEngine = engine }; await Assert.That(task.Execute()).IsFalse(); await Assert.That(engine.Errors.Count).IsEqualTo(1); - await Assert.That(engine.Errors[0].Message!).Contains("BVSDK1050"); + await Assert.That(engine.Errors[0].Message).Contains("BVSDK1050"); } [Test] @@ -146,5 +146,5 @@ private static ComputeVersion RunTask(TempGitRepo repo, RecordingBuildEngine? en } private static int CountVersionMessages(RecordingBuildEngine engine, string semVer) - => engine.Messages.Count(m => m.Message!.Contains($"Computed version {semVer}", StringComparison.Ordinal)); + => engine.Messages.Count(m => m.Message?.Contains($"Computed version {semVer}", StringComparison.Ordinal) is true); } diff --git a/tests/Buildvana.Sdk.Tasks.Tests/ConvertPfxToSnkTests.cs b/tests/Buildvana.Sdk.Tasks.Tests/ConvertPfxToSnkTests.cs index df1d162c..3315ec7a 100644 --- a/tests/Buildvana.Sdk.Tasks.Tests/ConvertPfxToSnkTests.cs +++ b/tests/Buildvana.Sdk.Tasks.Tests/ConvertPfxToSnkTests.cs @@ -43,7 +43,7 @@ await RunInTempDirectory(async (engine, directory) => { var outputPath = Path.Combine(directory, "key.snk"); await Assert.That(CreateTask(engine, pfxPath, "wrong", outputPath).Execute()).IsFalse(); await Assert.That(engine.Errors.Count).IsEqualTo(1); - await Assert.That(engine.Errors[0].Message!).Contains("BVSDK1201"); + await Assert.That(engine.Errors[0].Message).Contains("BVSDK1201"); }).ConfigureAwait(false); } @@ -55,7 +55,7 @@ await RunInTempDirectory(async (engine, directory) => { var outputPath = Path.Combine(directory, "key.snk"); await Assert.That(CreateTask(engine, pfxPath, Password, outputPath).Execute()).IsFalse(); await Assert.That(engine.Errors.Count).IsEqualTo(1); - await Assert.That(engine.Errors[0].Message!).Contains("BVSDK1202"); + await Assert.That(engine.Errors[0].Message).Contains("BVSDK1202"); }).ConfigureAwait(false); } @@ -65,7 +65,7 @@ public async Task Execute_WithMissingPfxPath_LogsBVSDK1050() var engine = new RecordingBuildEngine(); await Assert.That(CreateTask(engine, string.Empty, Password, "key.snk").Execute()).IsFalse(); await Assert.That(engine.Errors.Count).IsEqualTo(1); - await Assert.That(engine.Errors[0].Message!).Contains("BVSDK1050"); + await Assert.That(engine.Errors[0].Message).Contains("BVSDK1050"); } private static ConvertPfxToSnk CreateTask(IBuildEngine engine, string pfxPath, string password, string outputPath) diff --git a/tests/Buildvana.Sdk.Tasks.Tests/TaskLoggingHelperReporterTests.cs b/tests/Buildvana.Sdk.Tasks.Tests/TaskLoggingHelperReporterTests.cs index 909eeef2..cbc1198b 100644 --- a/tests/Buildvana.Sdk.Tasks.Tests/TaskLoggingHelperReporterTests.cs +++ b/tests/Buildvana.Sdk.Tasks.Tests/TaskLoggingHelperReporterTests.cs @@ -11,6 +11,8 @@ internal sealed class TaskLoggingHelperReporterTests public async Task Constructor_NullLog_Throws() { var engine = new RecordingBuildEngine(); + + // ! Null on purpose: testing null detection await Assert.That(() => new TaskLoggingHelperReporter(null!, engine)).Throws(); } @@ -18,6 +20,8 @@ public async Task Constructor_NullLog_Throws() public async Task Constructor_NullEngine_Throws() { var task = new ReporterProbeTask { BuildEngine = new RecordingBuildEngine() }; + + // ! Null on purpose: testing null detection await Assert.That(() => new TaskLoggingHelperReporter(task.Log, null!)).Throws(); } @@ -72,6 +76,8 @@ public async Task Report_UnknownLevel_Throws() public async Task Report_NullMessage_Throws() { var (reporter, _) = CreateReporter(); + + // ! Null on purpose: testing null detection await Assert.That(() => reporter.Report(MessageLevel.Info, null!)).Throws(); } @@ -148,6 +154,8 @@ public async Task BeginActivity_Nested_TracksDepth() public async Task BeginActivity_NullTitle_Throws() { var (reporter, _) = CreateReporter(); + + // ! Null on purpose: testing null detection await Assert.That(() => reporter.BeginActivity(null!)).Throws(); } @@ -162,6 +170,8 @@ public async Task BeginActivity_EmptyTitle_Throws() public async Task ChildOutput_NullLine_Throws() { var (reporter, _) = CreateReporter(); + + // ! Null on purpose: testing null detection await Assert.That(() => reporter.ChildOutput(null!, null)).Throws(); } @@ -169,6 +179,8 @@ public async Task ChildOutput_NullLine_Throws() public async Task ChildError_NullLine_Throws() { var (reporter, _) = CreateReporter(); + + // ! Null on purpose: testing null detection await Assert.That(() => reporter.ChildError(null!, null)).Throws(); } diff --git a/tests/Buildvana.Sdk.Tasks.Tests/WritePackagePinDumpTests.cs b/tests/Buildvana.Sdk.Tasks.Tests/WritePackagePinDumpTests.cs index 08323ffe..577f5b77 100644 --- a/tests/Buildvana.Sdk.Tasks.Tests/WritePackagePinDumpTests.cs +++ b/tests/Buildvana.Sdk.Tasks.Tests/WritePackagePinDumpTests.cs @@ -144,7 +144,7 @@ public async Task Execute_WithMissingOutputDirectory_LogsError() var task = CreateTask(engine, string.Empty); await Assert.That(task.Execute()).IsFalse(); await Assert.That(engine.Errors.Count).IsEqualTo(1); - await Assert.That(engine.Errors[0].Message!).Contains("BVSDK1050"); + await Assert.That(engine.Errors[0].Message).Contains("BVSDK1050"); } private static WritePackagePinDump CreateTask(IBuildEngine engine, string outputDirectory) diff --git a/tests/Buildvana.Sdk.Tasks.Tests/WriteThisAssemblyConstantsFileTests.cs b/tests/Buildvana.Sdk.Tasks.Tests/WriteThisAssemblyConstantsFileTests.cs index 693dda8b..66c246d1 100644 --- a/tests/Buildvana.Sdk.Tasks.Tests/WriteThisAssemblyConstantsFileTests.cs +++ b/tests/Buildvana.Sdk.Tasks.Tests/WriteThisAssemblyConstantsFileTests.cs @@ -71,7 +71,7 @@ public async Task Execute_WithMissingOutputPath_LogsError() var task = CreateTask(engine, string.Empty, ("Answer", "42")); await Assert.That(task.Execute()).IsFalse(); await Assert.That(engine.Errors.Count).IsEqualTo(1); - await Assert.That(engine.Errors[0].Message!).Contains("BVSDK1050"); + await Assert.That(engine.Errors[0].Message).Contains("BVSDK1050"); } private static WriteThisAssemblyConstantsFile CreateTask( diff --git a/tests/Buildvana.Sdk.Tests/AdditionalAssemblyInfoModuleTests.cs b/tests/Buildvana.Sdk.Tests/AdditionalAssemblyInfoModuleTests.cs index b01f81cc..5754b970 100644 --- a/tests/Buildvana.Sdk.Tests/AdditionalAssemblyInfoModuleTests.cs +++ b/tests/Buildvana.Sdk.Tests/AdditionalAssemblyInfoModuleTests.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using Buildvana.Core.Testing; using Microsoft.Build.Definition; using Microsoft.Build.Evaluation; @@ -104,8 +103,5 @@ private static (string Value, string[] CompilerVisibleProperties) Evaluate( } private static string GetRealTargetsPath() - => typeof(AdditionalAssemblyInfoModuleTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RealAdditionalAssemblyInfoModuleCoreTargetsPath") - .Value!; + => typeof(AdditionalAssemblyInfoModuleTests).Assembly.GetMetadata("RealAdditionalAssemblyInfoModuleCoreTargetsPath"); } diff --git a/tests/Buildvana.Sdk.Tests/AssemblySigningModuleTests.cs b/tests/Buildvana.Sdk.Tests/AssemblySigningModuleTests.cs index 87a0ef11..bc98c16b 100644 --- a/tests/Buildvana.Sdk.Tests/AssemblySigningModuleTests.cs +++ b/tests/Buildvana.Sdk.Tests/AssemblySigningModuleTests.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Buildvana.Core.Testing; @@ -100,8 +99,5 @@ private static void WritePfx(string path) } private static string GetRealTargetsPath() - => typeof(AssemblySigningModuleTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RealAssemblySigningModuleTargetsPath") - .Value!; + => typeof(AssemblySigningModuleTests).Assembly.GetMetadata("RealAssemblySigningModuleTargetsPath"); } diff --git a/tests/Buildvana.Sdk.Tests/CheckRoslynVersionTests.cs b/tests/Buildvana.Sdk.Tests/CheckRoslynVersionTests.cs index 66d6ce5f..210b59c3 100644 --- a/tests/Buildvana.Sdk.Tests/CheckRoslynVersionTests.cs +++ b/tests/Buildvana.Sdk.Tests/CheckRoslynVersionTests.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using Buildvana.Core.Testing; using Microsoft.Build.Evaluation; using Microsoft.Build.Execution; @@ -71,8 +70,5 @@ private static (bool Succeeded, IReadOnlyList Errors) Run(string languag } private static string GetRealTargetsPath() - => typeof(CheckRoslynVersionTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RealSourceGeneratorsAfterModulesCoreTargetsPath") - .Value!; + => typeof(CheckRoslynVersionTests).Assembly.GetMetadata("RealSourceGeneratorsAfterModulesCoreTargetsPath"); } diff --git a/tests/Buildvana.Sdk.Tests/FileBasedAppsModuleTests.cs b/tests/Buildvana.Sdk.Tests/FileBasedAppsModuleTests.cs index acc86617..db7106ce 100644 --- a/tests/Buildvana.Sdk.Tests/FileBasedAppsModuleTests.cs +++ b/tests/Buildvana.Sdk.Tests/FileBasedAppsModuleTests.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using Buildvana.Core.Testing; using Microsoft.Build.Definition; using Microsoft.Build.Evaluation; @@ -129,8 +128,5 @@ private static string GetVersion(Project project, string itemType) ?.GetMetadataValue("Version") ?? string.Empty; private static string GetRealTargetsPath() - => typeof(FileBasedAppsModuleTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RealFileBasedAppsModuleTargetsPath") - .Value!; + => typeof(FileBasedAppsModuleTests).Assembly.GetMetadata("RealFileBasedAppsModuleTargetsPath"); } diff --git a/tests/Buildvana.Sdk.Tests/InnoSetupTargetsTests.cs b/tests/Buildvana.Sdk.Tests/InnoSetupTargetsTests.cs index 5c1e45cd..b865e553 100644 --- a/tests/Buildvana.Sdk.Tests/InnoSetupTargetsTests.cs +++ b/tests/Buildvana.Sdk.Tests/InnoSetupTargetsTests.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using Buildvana.Core.Testing; using Microsoft.Build.Evaluation; using Microsoft.Build.Execution; @@ -115,8 +114,5 @@ private static (bool Succeeded, Dictionary Items) R } private static string GetRealTargetsPath() - => typeof(InnoSetupTargetsTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RealAlternatePackInnoSetupTargetsPath") - .Value!; + => typeof(InnoSetupTargetsTests).Assembly.GetMetadata("RealAlternatePackInnoSetupTargetsPath"); } diff --git a/tests/Buildvana.Sdk.Tests/ModulePagesTests.cs b/tests/Buildvana.Sdk.Tests/ModulePagesTests.cs index 96eede45..00f2c36c 100644 --- a/tests/Buildvana.Sdk.Tests/ModulePagesTests.cs +++ b/tests/Buildvana.Sdk.Tests/ModulePagesTests.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using System.Text.RegularExpressions; using Buildvana.Core.Testing; @@ -11,10 +10,7 @@ // reads it. internal sealed partial class ModulePagesTests { - private static readonly string RepositoryRoot = typeof(ModulePagesTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RepositoryRoot") - .Value!; + private static readonly string RepositoryRoot = typeof(ModulePagesTests).Assembly.GetMetadata("RepositoryRoot"); // Every module has one page whose title names it, and every page names a module. [Test] diff --git a/tests/Buildvana.Sdk.Tests/PackableProjectTargetsTests.cs b/tests/Buildvana.Sdk.Tests/PackableProjectTargetsTests.cs index a523c464..b0e6527c 100644 --- a/tests/Buildvana.Sdk.Tests/PackableProjectTargetsTests.cs +++ b/tests/Buildvana.Sdk.Tests/PackableProjectTargetsTests.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using Buildvana.Core.Testing; using Microsoft.Build.Definition; using Microsoft.Build.Evaluation; @@ -69,8 +68,5 @@ private static (string RequireLicenseAcceptance, IReadOnlyList ErrorCode } private static string GetRealPackableProjectTargetsPath() - => typeof(PackableProjectTargetsTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RealNuGetPackPackableProjectTargetsPath") - .Value!; + => typeof(PackableProjectTargetsTests).Assembly.GetMetadata("RealNuGetPackPackableProjectTargetsPath"); } diff --git a/tests/Buildvana.Sdk.Tests/PinDumpFixture.cs b/tests/Buildvana.Sdk.Tests/PinDumpFixture.cs index 3e81af05..b6a4c777 100644 --- a/tests/Buildvana.Sdk.Tests/PinDumpFixture.cs +++ b/tests/Buildvana.Sdk.Tests/PinDumpFixture.cs @@ -1,9 +1,10 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; +using System.Diagnostics; using System.Text.Json; using Buildvana.Core.Dependencies; +using Buildvana.Core.Testing; using Microsoft.Build.Evaluation; using Microsoft.Build.Execution; @@ -32,7 +33,9 @@ public PinDumpFixture() public string WriteFile(string relativePath, string content) { var path = Path.Combine(ProjectDirectory, relativePath); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var directory = Path.GetDirectoryName(path); + Debug.Assert(directory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); + _ = Directory.CreateDirectory(directory); File.WriteAllText(path, content); return path; } @@ -60,10 +63,7 @@ .. Directory.EnumerateFiles(DumpDirectory) public void Dispose() => Directory.Delete(_root, recursive: true); private static string GetMetadata(string key) - => typeof(PinDumpFixture).Assembly - .GetCustomAttributes() - .First(attribute => attribute.Key == key) - .Value!; + => typeof(PinDumpFixture).Assembly.GetMetadata(key); private static PackagePinDump ReadDump(string path) => JsonSerializer.Deserialize(File.ReadAllText(path), PackagePinDumpJsonContext.Default.PackagePinDump) diff --git a/tests/Buildvana.Sdk.Tests/PublishToFoldersTargetsTests.cs b/tests/Buildvana.Sdk.Tests/PublishToFoldersTargetsTests.cs index c1264b44..3fc2ea20 100644 --- a/tests/Buildvana.Sdk.Tests/PublishToFoldersTargetsTests.cs +++ b/tests/Buildvana.Sdk.Tests/PublishToFoldersTargetsTests.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using Buildvana.Core.Testing; using Microsoft.Build.Evaluation; using Microsoft.Build.Execution; @@ -112,8 +111,5 @@ private static (bool Succeeded, Dictionary Items) R } private static string GetRealTargetsPath() - => typeof(PublishToFoldersTargetsTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RealAlternatePackPublishToFoldersTargetsPath") - .Value!; + => typeof(PublishToFoldersTargetsTests).Assembly.GetMetadata("RealAlternatePackPublishToFoldersTargetsPath"); } diff --git a/tests/Buildvana.Sdk.Tests/ReadmeTargetsTests.cs b/tests/Buildvana.Sdk.Tests/ReadmeTargetsTests.cs index e039b545..14797711 100644 --- a/tests/Buildvana.Sdk.Tests/ReadmeTargetsTests.cs +++ b/tests/Buildvana.Sdk.Tests/ReadmeTargetsTests.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using Buildvana.Core.Testing; using Microsoft.Build.Definition; using Microsoft.Build.Evaluation; @@ -34,7 +33,9 @@ public async Task Evaluate_PackageReadmeFileNamesMissingFile_ReportsBVSDK1510() await Assert.That(errors).IsEquivalentTo(["BVSDK1510"]); } - private static (string ReadmePath, IReadOnlyList ErrorCodes) Evaluate(TempHome home, string packageReadmeFile) + private static (string ReadmePath, IReadOnlyList ErrorCodes) Evaluate( + TempHome home, + string packageReadmeFile) { var projectPath = home.GetFullPath("src/Test/Test.proj"); var projectText = $""" @@ -54,8 +55,5 @@ private static (string ReadmePath, IReadOnlyList ErrorCodes) Evaluate(Te } private static string GetRealReadmeTargetsPath() - => typeof(ReadmeTargetsTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RealNuGetPackReadmeTargetsPath") - .Value!; + => typeof(ReadmeTargetsTests).Assembly.GetMetadata("RealNuGetPackReadmeTargetsPath"); } diff --git a/tests/Buildvana.Sdk.Tests/ReleaseAssetListModuleTests.cs b/tests/Buildvana.Sdk.Tests/ReleaseAssetListModuleTests.cs index 414a8983..2d6496c7 100644 --- a/tests/Buildvana.Sdk.Tests/ReleaseAssetListModuleTests.cs +++ b/tests/Buildvana.Sdk.Tests/ReleaseAssetListModuleTests.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using Buildvana.Core.Testing; using Microsoft.Build.Definition; using Microsoft.Build.Evaluation; @@ -117,8 +116,5 @@ private static (bool Succeeded, bool ListExists, string[] Lines) Run( } private static string GetRealTargetsPath() - => typeof(ReleaseAssetListModuleTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RealReleaseAssetListModuleTargetsPath") - .Value!; + => typeof(ReleaseAssetListModuleTests).Assembly.GetMetadata("RealReleaseAssetListModuleTargetsPath"); } diff --git a/tests/Buildvana.Sdk.Tests/SdkDiagnosticsPageTests.cs b/tests/Buildvana.Sdk.Tests/SdkDiagnosticsPageTests.cs index 0e270e03..6742204e 100644 --- a/tests/Buildvana.Sdk.Tests/SdkDiagnosticsPageTests.cs +++ b/tests/Buildvana.Sdk.Tests/SdkDiagnosticsPageTests.cs @@ -2,7 +2,6 @@ // See the LICENSE file in the project root for full license information. using System.Globalization; -using System.Reflection; using System.Text.RegularExpressions; using Buildvana.Core.Testing; @@ -15,10 +14,7 @@ internal sealed partial class SdkDiagnosticsPageTests { private const string CodePrefix = "BVSDK"; - private static readonly string RepositoryRoot = typeof(SdkDiagnosticsPageTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RepositoryRoot") - .Value!; + private static readonly string RepositoryRoot = typeof(SdkDiagnosticsPageTests).Assembly.GetMetadata("RepositoryRoot"); // Every code the SDK raises is on the page, and every code on the page is raised. [Test] diff --git a/tests/Buildvana.Sdk.Tests/SdkPropsFixture.cs b/tests/Buildvana.Sdk.Tests/SdkPropsFixture.cs index 824481a6..febea932 100644 --- a/tests/Buildvana.Sdk.Tests/SdkPropsFixture.cs +++ b/tests/Buildvana.Sdk.Tests/SdkPropsFixture.cs @@ -1,7 +1,8 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; +using System.Diagnostics; +using Buildvana.Core.Testing; using Microsoft.Build.Definition; using Microsoft.Build.Evaluation; @@ -49,7 +50,9 @@ public SdkPropsFixture() public void WriteFile(string relativePath, string content = "") { var path = Path.Combine(RepoDirectory, relativePath); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var directory = Path.GetDirectoryName(path); + Debug.Assert(directory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); + _ = Directory.CreateDirectory(directory); File.WriteAllText(path, content); } @@ -123,10 +126,7 @@ private static (string SdkPropsPath, string SdkTargetsPath) BuildSdkLayout(strin } private static string GetRealPath(string key) - => typeof(SdkPropsFixture).Assembly - .GetCustomAttributes() - .First(attribute => attribute.Key == key) - .Value!; + => typeof(SdkPropsFixture).Assembly.GetMetadata(key); private static SdkEvaluationResult EvaluateProject(string projectPath) { diff --git a/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTargetsTests.cs b/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTargetsTests.cs index d318a0a0..86bb8941 100644 --- a/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTargetsTests.cs +++ b/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTargetsTests.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using Buildvana.Core.Testing; using Microsoft.Build.Definition; using Microsoft.Build.Evaluation; @@ -276,8 +275,5 @@ .. project.GetItems("AdditionalFiles") ]; private static string GetRealTargetsPath() - => typeof(StandardAnalyzersModuleTargetsTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RealStandardAnalyzersModuleTargetsPath") - .Value!; + => typeof(StandardAnalyzersModuleTargetsTests).Assembly.GetMetadata("RealStandardAnalyzersModuleTargetsPath"); } diff --git a/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTests.cs b/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTests.cs index 7cbd80f2..c3771652 100644 --- a/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTests.cs +++ b/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTests.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using Buildvana.Core.Testing; using Microsoft.Build.Definition; using Microsoft.Build.Evaluation; @@ -227,8 +226,5 @@ private static void WritePair(TempHome home, string directory, string prefix) } private static string GetRealTargetsPath() - => typeof(StandardAnalyzersModuleTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RealStandardAnalyzersUsePublicApiAnalyzersTargetsPath") - .Value!; + => typeof(StandardAnalyzersModuleTests).Assembly.GetMetadata("RealStandardAnalyzersUsePublicApiAnalyzersTargetsPath"); } diff --git a/tests/Buildvana.Sdk.Tests/TransitiveOverridesFixture.cs b/tests/Buildvana.Sdk.Tests/TransitiveOverridesFixture.cs index ffdddcfb..acbac242 100644 --- a/tests/Buildvana.Sdk.Tests/TransitiveOverridesFixture.cs +++ b/tests/Buildvana.Sdk.Tests/TransitiveOverridesFixture.cs @@ -1,7 +1,8 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; +using System.Diagnostics; +using Buildvana.Core.Testing; using Microsoft.Build.Definition; using Microsoft.Build.Evaluation; @@ -23,7 +24,8 @@ public TransitiveOverridesFixture() public void WriteHomeFile(string fileName, string content) => Write(Path.Combine(_root, fileName), content); - public void WriteProjectFile(string fileName, string content) => Write(Path.Combine(ProjectDirectory, fileName), content); + public void WriteProjectFile(string fileName, string content) + => Write(Path.Combine(ProjectDirectory, fileName), content); // The identities of the items of one type the evaluation ends up with, in evaluation order. public IReadOnlyList EvaluateItems(string itemType, bool suppressOverrides = false) @@ -52,14 +54,13 @@ public IReadOnlyList EvaluateItems(string itemType, bool suppressOverrid public void Dispose() => Directory.Delete(_root, recursive: true); private static string GetMetadata(string key) - => typeof(TransitiveOverridesFixture).Assembly - .GetCustomAttributes() - .First(attribute => attribute.Key == key) - .Value!; + => typeof(TransitiveOverridesFixture).Assembly.GetMetadata(key); private static void Write(string path, string content) { - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var directory = Path.GetDirectoryName(path); + Debug.Assert(directory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); + _ = Directory.CreateDirectory(directory); File.WriteAllText(path, content); } } diff --git a/tests/Buildvana.Sdk.Tests/XmlDocumentationModuleTests.cs b/tests/Buildvana.Sdk.Tests/XmlDocumentationModuleTests.cs index 2057ad3e..de5bfcea 100644 --- a/tests/Buildvana.Sdk.Tests/XmlDocumentationModuleTests.cs +++ b/tests/Buildvana.Sdk.Tests/XmlDocumentationModuleTests.cs @@ -1,7 +1,6 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reflection; using Buildvana.Core.Testing; using Microsoft.Build.Definition; using Microsoft.Build.Evaluation; @@ -112,8 +111,5 @@ private static (string GenerateDocumentationFile, string DocumentationFile, stri } private static string GetRealPath(string key) - => typeof(XmlDocumentationModuleTests).Assembly - .GetCustomAttributes() - .First(attribute => attribute.Key == key) - .Value!; + => typeof(XmlDocumentationModuleTests).Assembly.GetMetadata(key); } diff --git a/tests/Buildvana.Tool.Tests/AdditionalGroupPinReaderTests.cs b/tests/Buildvana.Tool.Tests/AdditionalGroupPinReaderTests.cs index b4157e28..f090dd9f 100644 --- a/tests/Buildvana.Tool.Tests/AdditionalGroupPinReaderTests.cs +++ b/tests/Buildvana.Tool.Tests/AdditionalGroupPinReaderTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using Buildvana.Core; using Buildvana.Core.ConsoleOutput; using Buildvana.Core.Process; @@ -14,20 +15,21 @@ internal sealed class AdditionalGroupPinReaderTests { private const string GroupFile = "src/Sdk/PackageVersions.props"; - private const string GroupFileContent = """ - - - - - - - """; + private const string GroupFileContent = + """ + + + + + + + """; [Test] public async Task ReadAsync_StatesThePinsOfEveryGroupFile() { using var home = new TempHome(); - Write(home, GroupFile, GroupFileContent); + home.WriteFile(GroupFile, GroupFileContent); var runner = Answer(home, ("StyleCop.Analyzers", "1.2.0-beta.556", "patch-"), ("Tools.InnoSetup", "7.1.0", null)); var pins = await ReadAsync(home, runner).ConfigureAwait(false); await Assert.That(pins.Select(static pin => pin.Id + " " + pin.VersionText)) @@ -43,7 +45,7 @@ await Assert.That(pins.Select(static pin => pin.Id + " " + pin.VersionText)) public async Task ReadAsync_AsksMsBuildForTheGroupsItemName() { using var home = new TempHome(); - Write(home, GroupFile, GroupFileContent); + home.WriteFile(GroupFile, GroupFileContent); var runner = Answer(home, ("Tools.InnoSetup", "7.1.0", null)); _ = await ReadAsync(home, runner).ConfigureAwait(false); var args = string.Join(" ", runner.Runs.Single().Args); @@ -69,7 +71,7 @@ public async Task ReadAsync_WithNoGroup_EvaluatesNothing() public async Task ReadAsync_LeavesOutAnItemDeclaredOutsideTheGlob() { using var home = new TempHome(); - Write(home, GroupFile, GroupFileContent); + home.WriteFile(GroupFile, GroupFileContent); var elsewhere = home.GetFullPath("src/Other/Imported.props"); var runner = Answer([("Tools.InnoSetup", "7.1.0", null)], declaringFile: elsewhere); await Assert.That(await ReadAsync(home, runner).ConfigureAwait(false)).IsEmpty(); @@ -79,7 +81,7 @@ public async Task ReadAsync_LeavesOutAnItemDeclaredOutsideTheGlob() public async Task ReadAsync_LeavesTheFamilyOut() { using var home = new TempHome(); - Write(home, GroupFile, GroupFileContent); + home.WriteFile(GroupFile, GroupFileContent); var runner = Answer(home, ("Buildvana.Runtime", "2.1.0", null)); await Assert.That(await ReadAsync(home, runner).ConfigureAwait(false)).IsEmpty(); } @@ -101,7 +103,7 @@ public async Task ReadAsync_OfAVersionStatedThroughAProperty_IsIndirect() """; using var home = new TempHome(); - Write(home, GroupFile, content); + home.WriteFile(GroupFile, content); var runner = Answer(home, ("Tools.InnoSetup", "7.1.0", null)); var pins = await ReadAsync(home, runner).ConfigureAwait(false); await Assert.That(pins.Single().Management).IsEqualTo(PinManagement.IndirectVersion); @@ -126,7 +128,7 @@ public async Task ReadAsync_OfAVersionStatedAsAChildElement_IsManaged() """; using var home = new TempHome(); - Write(home, GroupFile, content); + home.WriteFile(GroupFile, content); var runner = Answer(home, ("Tools.InnoSetup", @"\n 7.1.0\n ", null)); var pin = (await ReadAsync(home, runner).ConfigureAwait(false)).Single(); await Assert.That(pin.Management).IsEqualTo(PinManagement.Managed); @@ -137,7 +139,7 @@ public async Task ReadAsync_OfAVersionStatedAsAChildElement_IsManaged() public async Task ReadAsync_WhenEvaluationFails_ReportsAFailedStep() { using var home = new TempHome(); - Write(home, GroupFile, GroupFileContent); + home.WriteFile(GroupFile, GroupFileContent); var runner = new FakeProcessRunner { OnRun = static (executable, _) => new ProcessResult(executable, 1, "error MSB4025: no.", string.Empty, TimeSpan.Zero), @@ -146,7 +148,8 @@ public async Task ReadAsync_WhenEvaluationFails_ReportsAFailedStep() var reader = CreateReader(home, runner); var exception = await Assert.That(async () => await reader.ReadAsync().ConfigureAwait(false)) .Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(3); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + await Assert.That(exception.ExitCode).IsEqualTo(3); } private static Task> ReadAsync(TempHome home, IProcessRunner runner) @@ -196,11 +199,4 @@ private static FakeProcessRunner Answer( } private static string JsonPath(string path) => "\"" + path.Replace(@"\", @"\\", StringComparison.Ordinal) + "\""; - - private static void Write(TempHome home, string relativePath, string content) - { - var path = home.GetFullPath(relativePath); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.WriteAllText(path, content); - } } diff --git a/tests/Buildvana.Tool.Tests/AnchoringHomeDirectoryProviderTests.cs b/tests/Buildvana.Tool.Tests/AnchoringHomeDirectoryProviderTests.cs index f03f18ba..422241b7 100644 --- a/tests/Buildvana.Tool.Tests/AnchoringHomeDirectoryProviderTests.cs +++ b/tests/Buildvana.Tool.Tests/AnchoringHomeDirectoryProviderTests.cs @@ -54,8 +54,9 @@ public async Task HomeDirectory_WhenAnchoringFails_PropagatesAndLeavesCurrentDir var provider = new AnchoringHomeDirectoryProvider(new FixedHomeDirectoryProvider(directory.FullName)); directory.Delete(); - var exception = await Assert.That(() => provider.HomeDirectory).Throws(); - await Assert.That(exception!.Message).Contains("the current directory"); + await Assert.That(() => provider.HomeDirectory) + .Throws() + .WithMessageContaining("the current directory", StringComparison.Ordinal); await Assert.That(Directory.GetCurrentDirectory()).IsEqualTo(previousCurrentDirectory); } } diff --git a/tests/Buildvana.Tool.Tests/AppDirectiveEditorTests.cs b/tests/Buildvana.Tool.Tests/AppDirectiveEditorTests.cs index 75060a06..7e768da8 100644 --- a/tests/Buildvana.Tool.Tests/AppDirectiveEditorTests.cs +++ b/tests/Buildvana.Tool.Tests/AppDirectiveEditorTests.cs @@ -189,7 +189,7 @@ public async Task RewriteVersions_SplicesOnlyTheVersionTexts() ["Fake"] = "0.0.1", }; - var changed = AppDirectiveEditor.RewriteVersions(path, d => newVersions.GetValueOrDefault(d.Id)); + var changed = AppDirectiveEditor.RewriteVersions(path, (d, _) => newVersions.GetValueOrDefault(d.Id)); await Assert.That(changed).IsTrue(); await Assert.That(home.ReadFile(FileName)).IsEqualTo(""" @@ -214,15 +214,15 @@ public async Task RewriteVersions_OffersOnlyVersionedDirectives() using System; """; var path = WriteFile(home, content); - var offered = new List(); + var offered = new List<(AppDirective Directive, string VersionText)>(); - var changed = AppDirectiveEditor.RewriteVersions(path, d => { - offered.Add(d); + var changed = AppDirectiveEditor.RewriteVersions(path, (d, versionText) => { + offered.Add((d, versionText)); return null; }); await Assert.That(changed).IsFalse(); - await Assert.That(offered).IsEquivalentTo([new AppDirective(AppDirectiveKind.Package, "Beta", "1.0.0")]); + await Assert.That(offered).IsEquivalentTo([(new AppDirective(AppDirectiveKind.Package, "Beta", "1.0.0"), "1.0.0")]); await Assert.That(home.ReadFile(FileName)).IsEqualTo(content); } @@ -236,7 +236,7 @@ public async Task RewriteVersions_LeavesTheFileAlone_WhenTheNewTextEqualsTheOld( """; var path = WriteFile(home, content); - var changed = AppDirectiveEditor.RewriteVersions(path, _ => "1.0.0"); + var changed = AppDirectiveEditor.RewriteVersions(path, (_, _) => "1.0.0"); await Assert.That(changed).IsFalse(); await Assert.That(home.ReadFile(FileName)).IsEqualTo(content); @@ -253,7 +253,7 @@ public async Task RewriteVersions_PreservesCrOnlyLineEndings() const string expected = "#:package Alpha@1.0.1\rusing System;\r"; await File.WriteAllTextAsync(path, content).ConfigureAwait(false); - var changed = AppDirectiveEditor.RewriteVersions(path, _ => "1.0.1"); + var changed = AppDirectiveEditor.RewriteVersions(path, (_, _) => "1.0.1"); await Assert.That(changed).IsTrue(); var rewritten = await File.ReadAllTextAsync(path).ConfigureAwait(false); @@ -269,7 +269,7 @@ public async Task RewriteVersions_PreservesCrlfLineEndings() const string expected = "// Example hook.\r\n#:package Alpha@1.0.1\r\nusing System;\r\n"; await File.WriteAllTextAsync(path, content).ConfigureAwait(false); - var changed = AppDirectiveEditor.RewriteVersions(path, _ => "1.0.1"); + var changed = AppDirectiveEditor.RewriteVersions(path, (_, _) => "1.0.1"); await Assert.That(changed).IsTrue(); var rewritten = await File.ReadAllTextAsync(path).ConfigureAwait(false); @@ -292,7 +292,7 @@ public async Task RewriteVersions_PreservesTheByteOrderMark(bool hasByteOrderMar var contentBytes = Encoding.UTF8.GetBytes(content); await File.WriteAllBytesAsync(path, hasByteOrderMark ? [0xEF, 0xBB, 0xBF, .. contentBytes] : contentBytes).ConfigureAwait(false); - var changed = AppDirectiveEditor.RewriteVersions(path, _ => "1.0.1"); + var changed = AppDirectiveEditor.RewriteVersions(path, (_, _) => "1.0.1"); await Assert.That(changed).IsTrue(); var rewrittenBytes = await File.ReadAllBytesAsync(path).ConfigureAwait(false); diff --git a/tests/Buildvana.Tool.Tests/BvOptionAttributeTests.cs b/tests/Buildvana.Tool.Tests/BvOptionAttributeTests.cs index 83f1ee77..eeb7c86f 100644 --- a/tests/Buildvana.Tool.Tests/BvOptionAttributeTests.cs +++ b/tests/Buildvana.Tool.Tests/BvOptionAttributeTests.cs @@ -34,9 +34,10 @@ public async Task FlagTemplate_ParsesLongNameAndNoValuePlaceholder() [Test] public async Task Attribute_IsDiscoverableViaReflection() { - var property = typeof(BvOptionAttributeTests).GetProperty(nameof(SampleValueOption), BindingFlags.Instance | BindingFlags.NonPublic); - var attribute = property!.GetCustomAttribute(); - await Assert.That(attribute).IsNotNull(); - await Assert.That(attribute!.ValueName).IsEqualTo("NAME"); + var property = typeof(BvOptionAttributeTests).GetProperty( + nameof(SampleValueOption), + BindingFlags.Instance | BindingFlags.NonPublic); + var attribute = await Assert.That(property?.GetCustomAttribute()).IsNotNull(); + await Assert.That(attribute.ValueName).IsEqualTo("NAME"); } } diff --git a/tests/Buildvana.Tool.Tests/CIPlatformTests.cs b/tests/Buildvana.Tool.Tests/CIPlatformTests.cs index 330ee7a3..4109a06d 100644 --- a/tests/Buildvana.Tool.Tests/CIPlatformTests.cs +++ b/tests/Buildvana.Tool.Tests/CIPlatformTests.cs @@ -28,7 +28,7 @@ public async Task Detect_WithGitHubActionsTrue_ReturnsGitHubActionsPlatform(stri await Assert.That(platform).IsTypeOf(); await Assert.That(platform.IsCI).IsTrue(); - await Assert.That(platform.BotIdentity!.Name).IsEqualTo("github-actions[bot]"); + await Assert.That(platform.BotIdentity?.Name).IsEqualTo("github-actions[bot]"); await Assert.That(platform.HasStepOutputs).IsTrue(); } @@ -46,8 +46,8 @@ public async Task Detect_WithGiteaActionsTrue_ReturnsGiteaActionsPlatform(string await Assert.That(platform).IsTypeOf(); await Assert.That(platform.IsCI).IsTrue(); - await Assert.That(platform.BotIdentity!.Name).IsEqualTo("gitea-actions"); - await Assert.That(platform.BotIdentity!.Email).IsEqualTo("gitea-actions@noreply.gitea.example.invalid"); + await Assert.That(platform.BotIdentity?.Name).IsEqualTo("gitea-actions"); + await Assert.That(platform.BotIdentity?.Email).IsEqualTo("gitea-actions@noreply.gitea.example.invalid"); await Assert.That(platform.HasStepOutputs).IsTrue(); } @@ -61,8 +61,8 @@ public async Task Detect_WithForgejoActionsTrue_ReturnsForgejoActionsPlatform(st await Assert.That(platform).IsTypeOf(); await Assert.That(platform.IsCI).IsTrue(); - await Assert.That(platform.BotIdentity!.Name).IsEqualTo("forgejo-actions"); - await Assert.That(platform.BotIdentity!.Email).IsEqualTo("noreply@forgejo.org"); + await Assert.That(platform.BotIdentity?.Name).IsEqualTo("forgejo-actions"); + await Assert.That(platform.BotIdentity?.Email).IsEqualTo("noreply@forgejo.org"); await Assert.That(platform.HasStepOutputs).IsTrue(); } @@ -76,7 +76,7 @@ public async Task Detect_WithGitLabCISet_ReturnsGitLabCIPlatform(string value) await Assert.That(platform).IsTypeOf(); await Assert.That(platform.IsCI).IsTrue(); - await Assert.That(platform.BotIdentity!.Email).IsEqualTo("gitlab-ci@noreply.gitlab.example.invalid"); + await Assert.That(platform.BotIdentity?.Email).IsEqualTo("gitlab-ci@noreply.gitlab.example.invalid"); await Assert.That(platform.HasStepOutputs).IsFalse(); } @@ -165,8 +165,9 @@ public async Task OpenStepOutputs_WithoutOutputFile_Fails(string marker, string { var platform = Detect((marker, "true"), (outputVariable, path)); - var exception = await Assert.That(platform.OpenStepOutputs).Throws(); - await Assert.That(exception!.Message).Contains(outputVariable); + await Assert.That(platform.OpenStepOutputs) + .Throws() + .WithMessageContaining(outputVariable, StringComparison.Ordinal); } // Forgejo runners before v9.0.0 set GITHUB_OUTPUT and no FORGEJO_OUTPUT. @@ -176,8 +177,9 @@ public async Task OpenStepOutputs_OnForgejoActionsWithGitHubOutputOnly_Fails() using var home = new TempHome(); var platform = Detect(("FORGEJO_ACTIONS", "true"), ("GITHUB_OUTPUT", home.GetFullPath("output.txt"))); - var exception = await Assert.That(platform.OpenStepOutputs).Throws(); - await Assert.That(exception!.Message).Contains("FORGEJO_OUTPUT"); + await Assert.That(platform.OpenStepOutputs) + .Throws() + .WithMessageContaining("FORGEJO_OUTPUT", StringComparison.Ordinal); } [Test] @@ -200,7 +202,7 @@ public async Task Detect_OnAPlatformWithAServer_TakesTheServerFromItsVariable( { var platform = Detect((marker, "true"), (serverVariable, "https://git.example.invalid")); - await Assert.That(platform.ServerUrl!.Host).IsEqualTo("git.example.invalid"); + await Assert.That(platform.ServerUrl?.Host).IsEqualTo("git.example.invalid"); await Assert.That(platform.ServerKind).IsEqualTo(kind); } diff --git a/tests/Buildvana.Tool.Tests/ChangelogUpdaterTests.cs b/tests/Buildvana.Tool.Tests/ChangelogUpdaterTests.cs index 4ec5d9a2..1ad188fe 100644 --- a/tests/Buildvana.Tool.Tests/ChangelogUpdaterTests.cs +++ b/tests/Buildvana.Tool.Tests/ChangelogUpdaterTests.cs @@ -68,8 +68,7 @@ public async Task HasUnreleasedChanges_FailsWhenThereAreNoSections() string[] lines = ["# Changelog", string.Empty, "Nothing to see here."]; bool Act() => ChangelogUpdater.HasUnreleasedChanges(lines); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).IsEqualTo("CHANGELOG.md contains no sections."); + await Assert.That(Act).Throws().WithMessage("CHANGELOG.md contains no sections.", StringComparison.Ordinal); } [Test] @@ -321,8 +320,7 @@ public async Task PrepareForRelease_FailsWhenThereAreNoSections() string[] lines = ["# Changelog", string.Empty, "Nothing to see here."]; string Act() => PrepareForRelease(lines); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).IsEqualTo("CHANGELOG.md contains no sections."); + await Assert.That(Act).Throws().WithMessage("CHANGELOG.md contains no sections.", StringComparison.Ordinal); } // The title is only needed when a new section is actually written: a changelog that fails validation @@ -521,8 +519,9 @@ public async Task FinalizeNewSection_FailsWhenThereIsOnlyOneSection() string Act() => FinalizeNewSection(lines); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).IsEqualTo("CHANGELOG.md contains only one section."); + await Assert.That(Act) + .Throws() + .WithMessage("CHANGELOG.md contains only one section.", StringComparison.Ordinal); } [Test] @@ -531,8 +530,7 @@ public async Task FinalizeNewSection_FailsWhenThereAreNoSections() string[] lines = ["# Changelog", string.Empty, "Nothing to see here."]; string Act() => FinalizeNewSection(lines); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).IsEqualTo("CHANGELOG.md contains no sections."); + await Assert.That(Act).Throws().WithMessage("CHANGELOG.md contains no sections.", StringComparison.Ordinal); } // Same guarantee as PrepareForRelease: the title is composed only when a heading is actually replaced. diff --git a/tests/Buildvana.Tool.Tests/CommandArgumentValidatorTests.cs b/tests/Buildvana.Tool.Tests/CommandArgumentValidatorTests.cs index b9c83ad0..c8d4da55 100644 --- a/tests/Buildvana.Tool.Tests/CommandArgumentValidatorTests.cs +++ b/tests/Buildvana.Tool.Tests/CommandArgumentValidatorTests.cs @@ -10,7 +10,7 @@ internal sealed class CommandArgumentValidatorTests [Test] public async Task ForwardingCommand_AcceptsTokensAfterSeparator() { - var command = CommandRegistry.Find("build")!; + var command = await Assert.That(CommandRegistry.Find("build")).IsNotNull(); var parsed = CliArgSplitter.Split(["build", "--", "-p:Foo=Bar"]); var parameters = CommandArgumentValidator.Validate(command, parsed, parsed.Positionals); await Assert.That(Join(parameters.Forwarded)).IsEqualTo("-p:Foo=Bar"); @@ -19,7 +19,7 @@ public async Task ForwardingCommand_AcceptsTokensAfterSeparator() [Test] public async Task ForwardingCommand_RejectsOptionsBeforeSeparator() { - var command = CommandRegistry.Find("build")!; + var command = await Assert.That(CommandRegistry.Find("build")).IsNotNull(); var parsed = CliArgSplitter.Split(["build", "-p:Foo"]); await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, parsed.Positionals)).Throws(); } @@ -27,7 +27,7 @@ public async Task ForwardingCommand_RejectsOptionsBeforeSeparator() [Test] public async Task ForwardingCommand_RejectsPositionals() { - var command = CommandRegistry.Find("build")!; + var command = await Assert.That(CommandRegistry.Find("build")).IsNotNull(); var parsed = CliArgSplitter.Split(["build", "extra"]); await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, parsed.Positionals)).Throws(); } @@ -35,7 +35,7 @@ public async Task ForwardingCommand_RejectsPositionals() [Test] public async Task NonForwardingCommand_RejectsTokensAfterSeparator() { - var command = CommandRegistry.Find("release")!; + var command = await Assert.That(CommandRegistry.Find("release")).IsNotNull(); var parsed = CliArgSplitter.Split(["release", "--", "x"]); await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, parsed.Positionals)).Throws(); } @@ -43,7 +43,7 @@ public async Task NonForwardingCommand_RejectsTokensAfterSeparator() [Test] public async Task NonForwardingCommand_AllowsItsOwnOptionTokens() { - var command = CommandRegistry.Find("release")!; + var command = await Assert.That(CommandRegistry.Find("release")).IsNotNull(); var parsed = CliArgSplitter.Split(["release", "-c", "Debug"]); var parameters = CommandArgumentValidator.Validate(command, parsed, parsed.Positionals); await Assert.That(Join(parameters.Options)).IsEqualTo("-c|Debug"); @@ -52,47 +52,47 @@ public async Task NonForwardingCommand_AllowsItsOwnOptionTokens() [Test] public async Task SettingsLessCommand_RejectsUnknownOption() { - var command = CommandRegistry.Find("clean")!; + var command = await Assert.That(CommandRegistry.Find("clean")).IsNotNull(); var parsed = CliArgSplitter.Split(["clean", "--bogus"]); - var exception = await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, parsed.Positionals)) - .Throws(); - await Assert.That(exception!.Message).IsEqualTo("Unknown option '--bogus' for command 'clean'."); + await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, parsed.Positionals)) + .Throws() + .WithMessage("Unknown option '--bogus' for command 'clean'.", StringComparison.Ordinal); } [Test] public async Task SettingsLessCommand_RejectsUnknownOption_ViaAlias() { - var command = CommandRegistry.Find("version")!; + var command = await Assert.That(CommandRegistry.Find("version")).IsNotNull(); var parsed = CliArgSplitter.Split(["version", "--bogus"]); - var exception = await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, parsed.Positionals)) - .Throws(); - await Assert.That(exception!.Message).IsEqualTo("Unknown option '--bogus' for command 'version show'."); + await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, parsed.Positionals)) + .Throws() + .WithMessage("Unknown option '--bogus' for command 'version show'.", StringComparison.Ordinal); } [Test] public async Task SettingsLessCommand_ReportsOffendingTokensInCommandLineOrder() { - var command = CommandRegistry.Find("clean")!; + var command = await Assert.That(CommandRegistry.Find("clean")).IsNotNull(); var parsed = CliArgSplitter.Split(["clean", "junk", "--bogus"]); - var exception = await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, parsed.Positionals)) - .Throws(); - await Assert.That(exception!.Message).IsEqualTo("Unexpected argument 'junk' for command 'clean'."); + await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, parsed.Positionals)) + .Throws() + .WithMessage("Unexpected argument 'junk' for command 'clean'.", StringComparison.Ordinal); } [Test] public async Task SettingsCarryingCommand_RejectsUnknownOption() { - var command = CommandRegistry.Find("release")!; + var command = await Assert.That(CommandRegistry.Find("release")).IsNotNull(); var parsed = CliArgSplitter.Split(["release", "--bogus"]); - var exception = await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, parsed.Positionals)) - .Throws(); - await Assert.That(exception!.Message).IsEqualTo("Unknown option '--bogus' for command 'release'."); + await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, parsed.Positionals)) + .Throws() + .WithMessage("Unknown option '--bogus' for command 'release'.", StringComparison.Ordinal); } [Test] public async Task SettingsCarryingCommand_ConsumesOptionValue() { - var command = CommandRegistry.Find("release")!; + var command = await Assert.That(CommandRegistry.Find("release")).IsNotNull(); var parsed = CliArgSplitter.Split(["release", "--bump", "minor"]); var parameters = CommandArgumentValidator.Validate(command, parsed, parsed.Positionals); await Assert.That(Join(parameters.Options)).IsEqualTo("--bump|minor"); @@ -102,7 +102,7 @@ public async Task SettingsCarryingCommand_ConsumesOptionValue() [Test] public async Task SettingsCarryingCommand_AllowsInlineOptionValue() { - var command = CommandRegistry.Find("release")!; + var command = await Assert.That(CommandRegistry.Find("release")).IsNotNull(); var parsed = CliArgSplitter.Split(["release", "--bump=minor"]); var parameters = CommandArgumentValidator.Validate(command, parsed, parsed.Positionals); await Assert.That(Join(parameters.Options)).IsEqualTo("--bump=minor"); @@ -111,7 +111,7 @@ public async Task SettingsCarryingCommand_AllowsInlineOptionValue() [Test] public async Task SettingsCarryingCommand_AllowsFlagOption() { - var command = CommandRegistry.Find("version advance")!; + var command = await Assert.That(CommandRegistry.Find("version advance")).IsNotNull(); var parsed = CliArgSplitter.Split(["version", "advance", "--force"]); var parameters = CommandArgumentValidator.Validate(command, parsed, []); await Assert.That(Join(parameters.Options)).IsEqualTo("--force"); @@ -120,17 +120,17 @@ public async Task SettingsCarryingCommand_AllowsFlagOption() [Test] public async Task SettingsCarryingCommand_RejectsValueOptionWithoutValue() { - var command = CommandRegistry.Find("release")!; + var command = await Assert.That(CommandRegistry.Find("release")).IsNotNull(); var parsed = CliArgSplitter.Split(["release", "--bump"]); - var exception = await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, parsed.Positionals)) - .Throws(); - await Assert.That(exception!.Message).IsEqualTo("Option '--bump' requires a value."); + await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, parsed.Positionals)) + .Throws() + .WithMessage("Option '--bump' requires a value.", StringComparison.Ordinal); } [Test] public async Task NonForwardingCommand_RejectsPositionals_WhenNoArgumentsDeclared() { - var command = CommandRegistry.Find("release")!; + var command = await Assert.That(CommandRegistry.Find("release")).IsNotNull(); var parsed = CliArgSplitter.Split(["release", "extra"]); await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, parsed.Positionals)).Throws(); } @@ -138,7 +138,7 @@ public async Task NonForwardingCommand_RejectsPositionals_WhenNoArgumentsDeclare [Test] public async Task ArgumentCommand_AcceptsDeclaredPositional() { - var command = CommandRegistry.Find("version advance")!; + var command = await Assert.That(CommandRegistry.Find("version advance")).IsNotNull(); var parsed = CliArgSplitter.Split(["version", "advance", "minor"]); var parameters = CommandArgumentValidator.Validate(command, parsed, ["minor"]); await Assert.That(Join(parameters.Positionals)).IsEqualTo("minor"); @@ -148,7 +148,7 @@ public async Task ArgumentCommand_AcceptsDeclaredPositional() [Test] public async Task ArgumentCommand_AcceptsOmittedOptionalArgument() { - var command = CommandRegistry.Find("version advance")!; + var command = await Assert.That(CommandRegistry.Find("version advance")).IsNotNull(); var parsed = CliArgSplitter.Split(["version", "advance"]); var parameters = CommandArgumentValidator.Validate(command, parsed, []); await Assert.That(parameters.Positionals.Count).IsEqualTo(0); @@ -158,7 +158,7 @@ public async Task ArgumentCommand_AcceptsOmittedOptionalArgument() [Test] public async Task ArgumentCommand_RejectsExcessPositionals() { - var command = CommandRegistry.Find("version advance")!; + var command = await Assert.That(CommandRegistry.Find("version advance")).IsNotNull(); var parsed = CliArgSplitter.Split(["version", "advance", "minor", "extra"]); await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, ["minor", "extra"])).Throws(); } @@ -174,7 +174,11 @@ public async Task ArgumentCommand_RejectsMissingRequiredArgument() [Test] public async Task VariadicArgumentCommand_AcceptsMorePositionalsThanDeclaredArguments() { - var command = new CommandRegistration([["fake"]], typeof(FakeVariadicArgumentSettings), false, typeof(FakeVariadicArgumentSettings)); + var command = new CommandRegistration( + [["fake"]], + typeof(FakeVariadicArgumentSettings), + false, + typeof(FakeVariadicArgumentSettings)); var parsed = CliArgSplitter.Split(["fake", "mode", "one", "two", "three"]); await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, ["mode", "one", "two", "three"])).ThrowsNothing(); } @@ -182,7 +186,11 @@ public async Task VariadicArgumentCommand_AcceptsMorePositionalsThanDeclaredArgu [Test] public async Task VariadicArgumentCommand_AcceptsNoPositionalAtAll() { - var command = new CommandRegistration([["fake"]], typeof(FakeVariadicArgumentSettings), false, typeof(FakeVariadicArgumentSettings)); + var command = new CommandRegistration( + [["fake"]], + typeof(FakeVariadicArgumentSettings), + false, + typeof(FakeVariadicArgumentSettings)); var parsed = CliArgSplitter.Split(["fake"]); await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, [])).ThrowsNothing(); } @@ -190,7 +198,7 @@ public async Task VariadicArgumentCommand_AcceptsNoPositionalAtAll() [Test] public async Task OperandAfterFlag_BindsAsPositional() { - var command = CommandRegistry.Find("deps update")!; + var command = await Assert.That(CommandRegistry.Find("deps update")).IsNotNull(); var parsed = CliArgSplitter.Split(["deps", "update", "--check", "CommunityToolkit.Diagnostics"]); var parameters = CommandArgumentValidator.Validate(command, parsed, []); await Assert.That(Join(parameters.Positionals)).IsEqualTo("CommunityToolkit.Diagnostics"); @@ -200,7 +208,7 @@ public async Task OperandAfterFlag_BindsAsPositional() [Test] public async Task OperandAfterValueOption_BindsAsPositional() { - var command = CommandRegistry.Find("deps update")!; + var command = await Assert.That(CommandRegistry.Find("deps update")).IsNotNull(); var parsed = CliArgSplitter.Split(["deps", "update", "--to", "1.2.3", "Serilog"]); var parameters = CommandArgumentValidator.Validate(command, parsed, []); await Assert.That(Join(parameters.Positionals)).IsEqualTo("Serilog"); @@ -210,7 +218,7 @@ public async Task OperandAfterValueOption_BindsAsPositional() [Test] public async Task OperandsOnBothSidesOfAnOption_BindInCommandLineOrder() { - var command = CommandRegistry.Find("deps update")!; + var command = await Assert.That(CommandRegistry.Find("deps update")).IsNotNull(); var parsed = CliArgSplitter.Split(["deps", "update", "Serilog", "--check", "Louis"]); var parameters = CommandArgumentValidator.Validate(command, parsed, ["Serilog"]); await Assert.That(Join(parameters.Positionals)).IsEqualTo("Serilog|Louis"); @@ -220,21 +228,21 @@ public async Task OperandsOnBothSidesOfAnOption_BindInCommandLineOrder() [Test] public async Task UnknownOptionBeforeAnOperand_IsReportedFirst() { - var command = CommandRegistry.Find("deps update")!; + var command = await Assert.That(CommandRegistry.Find("deps update")).IsNotNull(); var parsed = CliArgSplitter.Split(["deps", "update", "--bogus", "Serilog"]); - var exception = await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, [])) - .Throws(); - await Assert.That(exception!.Message).IsEqualTo("Unknown option '--bogus' for command 'dependencies update'."); + await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, [])) + .Throws() + .WithMessage("Unknown option '--bogus' for command 'dependencies update'.", StringComparison.Ordinal); } [Test] public async Task ExcessOperandAfterAnOption_IsRejected() { - var command = CommandRegistry.Find("version advance")!; + var command = await Assert.That(CommandRegistry.Find("version advance")).IsNotNull(); var parsed = CliArgSplitter.Split(["version", "advance", "--force", "minor", "extra"]); - var exception = await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, [])) - .Throws(); - await Assert.That(exception!.Message).IsEqualTo("Unexpected argument 'extra' for command 'version advance'."); + await Assert.That(() => CommandArgumentValidator.Validate(command, parsed, [])) + .Throws() + .WithMessage("Unexpected argument 'extra' for command 'version advance'.", StringComparison.Ordinal); } [Test] diff --git a/tests/Buildvana.Tool.Tests/CommandLineOverridesParserTests.cs b/tests/Buildvana.Tool.Tests/CommandLineOverridesParserTests.cs index cc1f8abf..91363ad9 100644 --- a/tests/Buildvana.Tool.Tests/CommandLineOverridesParserTests.cs +++ b/tests/Buildvana.Tool.Tests/CommandLineOverridesParserTests.cs @@ -75,7 +75,7 @@ public async Task Parse_StripsConfigurationTokens_FromForwardedArgs() await Assert.That(overrides.Configuration).IsEqualTo("Debug"); await Assert.That(overrides.ForwardedArgs).IsNotNull(); - await Assert.That(string.Join('|', overrides.ForwardedArgs!)).IsEqualTo("--no-incremental"); + await Assert.That(string.Join('|', overrides.ForwardedArgs ?? [])).IsEqualTo("--no-incremental"); } [Test] @@ -86,7 +86,7 @@ public async Task Parse_KeepsOtherForwardedArgsVerbatim() await Assert.That(overrides.Configuration).IsNull(); await Assert.That(overrides.ForwardedArgs).IsNotNull(); - await Assert.That(string.Join('|', overrides.ForwardedArgs!)).IsEqualTo("-m:8|-v:minimal"); + await Assert.That(string.Join('|', overrides.ForwardedArgs ?? [])).IsEqualTo("-m:8|-v:minimal"); } // The cost of ownership: a trailing `-c` that was meant as another forwarded option's value is read diff --git a/tests/Buildvana.Tool.Tests/CommandPagesTests.cs b/tests/Buildvana.Tool.Tests/CommandPagesTests.cs index 8705fcc5..4971bcf2 100644 --- a/tests/Buildvana.Tool.Tests/CommandPagesTests.cs +++ b/tests/Buildvana.Tool.Tests/CommandPagesTests.cs @@ -12,10 +12,7 @@ // first: `-c, --configuration `. The repository root reaches the tests as ToolDiagnosticsPageTests reads it. internal sealed class CommandPagesTests { - private static readonly string RepositoryRoot = typeof(CommandPagesTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RepositoryRoot") - .Value!; + private static readonly string RepositoryRoot = typeof(CommandPagesTests).Assembly.GetMetadata("RepositoryRoot"); // The page under docs/tool-commands of each top-level command name. A group page holds one "Options" table // for all of its subcommands, with a "Subcommand" column. @@ -61,7 +58,7 @@ public async Task GlobalOptionsTable_ListsTheOptionsOfGlobalSettings() [MethodDataSource(nameof(CommandNames))] public async Task OptionsTable_ListsTheOptionsOfTheSettingsType(string name) { - var command = CommandRegistry.Find(name)!; + var command = await Assert.That(CommandRegistry.Find(name)).IsNotNull(); await Assert.That(Pages.Keys).Contains(command.CanonicalPath[0]); var page = LoadPage(Path.Combine("tool-commands", Pages[command.CanonicalPath[0]])); var listed = ListedOptions(page, command); @@ -84,8 +81,8 @@ private static IEnumerable RenderOptions(Type settingsType) => settingsType .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Select(static property => property.GetCustomAttribute()) - .Where(static option => option is not null) - .Select(static option => RenderOption(option!)); + .OfType() + .Select(RenderOption); private static string RenderOption(BvOptionAttribute option) { diff --git a/tests/Buildvana.Tool.Tests/CommandRegistryTests.cs b/tests/Buildvana.Tool.Tests/CommandRegistryTests.cs index 5e266274..5e2db7ba 100644 --- a/tests/Buildvana.Tool.Tests/CommandRegistryTests.cs +++ b/tests/Buildvana.Tool.Tests/CommandRegistryTests.cs @@ -132,8 +132,8 @@ public async Task BuildTree_LinksEveryDepthOfAnAliasPath() { var command = new CommandRegistration([["alpha", "show"], ["al", "show"]], typeof(object), false, null); var topLevelNodes = CommandRegistry.BuildTree([command]); - var alias = topLevelNodes.Single(n => n.Name == "al").FindChild("show")!; - await Assert.That(alias.CanonicalNode?.FullName).IsEqualTo("alpha show"); + var alias = topLevelNodes.Single(n => n.Name == "al").FindChild("show"); + await Assert.That(alias?.CanonicalNode?.FullName).IsEqualTo("alpha show"); } [Test] diff --git a/tests/Buildvana.Tool.Tests/ConsoleEncodingScopeTests.cs b/tests/Buildvana.Tool.Tests/ConsoleEncodingScopeTests.cs index ebe8a4d8..1c8c01d0 100644 --- a/tests/Buildvana.Tool.Tests/ConsoleEncodingScopeTests.cs +++ b/tests/Buildvana.Tool.Tests/ConsoleEncodingScopeTests.cs @@ -55,7 +55,7 @@ public async Task IsDefaultEncodingRequested_AcceptsOnlyTheLiteralOne(string? va [Arguments(typeof(InvalidOperationException), false)] public async Task IsConsoleEncodingFailure_AcceptsOnlyFailuresOfTheConsole(Type exceptionType, bool expected) { - var exception = (Exception)Activator.CreateInstance(exceptionType)!; + var exception = await Assert.That(Activator.CreateInstance(exceptionType) as Exception).IsNotNull(); await Assert.That(ConsoleEncodingScope.IsConsoleEncodingFailure(exception)).IsEqualTo(expected); } } diff --git a/tests/Buildvana.Tool.Tests/DelegationServiceTests.cs b/tests/Buildvana.Tool.Tests/DelegationServiceTests.cs index 2d4acd77..cc2bd2f9 100644 --- a/tests/Buildvana.Tool.Tests/DelegationServiceTests.cs +++ b/tests/Buildvana.Tool.Tests/DelegationServiceTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using System.Text.Json; using Buildvana.Core; using Buildvana.Core.Json; @@ -68,10 +69,9 @@ public async Task TryDelegate_WithManifestUnderDotConfig_Fails() var service = CreateService(runner); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning - var exception = await Assert.That(async () => await service.TryDelegateAsync(Context(home)).ConfigureAwait(false)) - .Throws(); - - await Assert.That(exception!.Message).Contains("git mv .config/dotnet-tools.json dotnet-tools.json"); + await Assert.That(async () => await service.TryDelegateAsync(Context(home)).ConfigureAwait(false)) + .Throws() + .WithMessageContaining("git mv .config/dotnet-tools.json dotnet-tools.json", StringComparison.Ordinal); await Assert.That(runner.Runs.Count).IsEqualTo(0); await Assert.That(runner.InheritedStdioRuns.Count).IsEqualTo(0); } @@ -91,8 +91,9 @@ public async Task TryDelegate_WithManifestsInBothPlaces_FailsNamingTheMerge() // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning var exception = await Assert.That(async () => await service.TryDelegateAsync(Context(home)).ConfigureAwait(false)) .Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); - await Assert.That(exception!.Message) + await Assert.That(exception.Message) .Contains("Merge the tools of .config/dotnet-tools.json into dotnet-tools.json, then delete .config/dotnet-tools.json."); await Assert.That(exception.Message).DoesNotContain("git mv"); } @@ -146,9 +147,15 @@ public async Task TryDelegate_WithUnreadableManifest_RunsInPlaceAndWarns() // A bv entry the dotnet CLI itself could not use (see ToolManifest.ReadBvPin) reads as "no pin", but // deserves a warning — the repository clearly meant to pin something. [Test] - [Arguments("""{ "version": 1, "isRoot": true, "tools": { "bv": { "version": "not-a-version", "commands": [ "bv" ] } } }""", "not-a-version")] - [Arguments("""{ "version": 1, "isRoot": true, "tools": { "bv": { "commands": [ "bv" ] } } }""", "has no version")] - public async Task TryDelegate_WithUnusableManifestEntry_RunsInPlaceAndWarns(string manifestContent, string expectedDetail) + [Arguments( + """{ "version": 1, "isRoot": true, "tools": { "bv": { "version": "not-a-version", "commands": [ "bv" ] } } }""", + "not-a-version")] + [Arguments( + """{ "version": 1, "isRoot": true, "tools": { "bv": { "commands": [ "bv" ] } } }""", + "has no version")] + public async Task TryDelegate_WithUnusableManifestEntry_RunsInPlaceAndWarns( + string manifestContent, + string expectedDetail) { using var home = new TempHome(); MarkAsHome(home); @@ -172,7 +179,9 @@ public async Task TryDelegate_WithDifferentlyCasedManifestKey_ReadsThePin() { using var home = new TempHome(); MarkAsHome(home); - WriteToolManifest(home, """{ "version": 1, "isRoot": true, "tools": { "BV": { "version": "2.1.40-preview", "commands": [ "bv" ] } } }"""); + WriteToolManifest( + home, + """{ "version": 1, "isRoot": true, "tools": { "BV": { "version": "2.1.40-preview", "commands": [ "bv" ] } } }"""); var runner = new FakeProcessRunner(); var output = new StringWriter(); var service = CreateService(runner, output); @@ -253,7 +262,8 @@ public async Task TryDelegate_RestoresThenRunsPinnedBvWithOriginalArguments() await Assert.That(restoreArgs).IsEquivalentTo(["tool", "restore"], CollectionOrdering.Matching); // Home-directory discovery reports the home path with a trailing separator, per its contract. - await Assert.That(Path.TrimEndingDirectorySeparator(restoreDirectory!)).IsEqualTo(home.RootPath); + var directory = await Assert.That(restoreDirectory).IsNotNull(); + await Assert.That(Path.TrimEndingDirectorySeparator(directory)).IsEqualTo(home.RootPath); await Assert.That(runner.InheritedStdioRuns.Count).IsEqualTo(1); var run = runner.InheritedStdioRuns[0]; @@ -263,9 +273,10 @@ public async Task TryDelegate_RestoresThenRunsPinnedBvWithOriginalArguments() await Assert.That(run.Args).IsEquivalentTo( ["tool", "run", "bv", "--", "build", "--verbosity", "detailed", "--", "/p:Answer=42"], CollectionOrdering.Matching); - await Assert.That(Path.TrimEndingDirectorySeparator(run.WorkingDirectory!)).IsEqualTo(home.RootPath); + var workingDirectory = await Assert.That(run.WorkingDirectory).IsNotNull(); + await Assert.That(Path.TrimEndingDirectorySeparator(workingDirectory)).IsEqualTo(home.RootPath); await Assert.That(run.Environment).IsNotNull(); - await Assert.That(run.Environment!["BV_DELEGATED"]).IsEqualTo(OwnVersion); + await Assert.That(run.Environment?["BV_DELEGATED"]).IsEqualTo(OwnVersion); } // The pinned bv is already installed: `dotnet tool run` needs no restore, so none must run — the common @@ -305,7 +316,12 @@ public async Task TryDelegate_StreamsRestoreOutput() { using var home = CreateDelegatableHome("2.1.40-preview"); var runner = new FakeProcessRunner { - OnRun = static (executable, args) => new ProcessResult($"{executable} {string.Join(' ', args)}", 0, "Restored bv.\nRestored ngbv.", string.Empty, TimeSpan.Zero), + OnRun = static (executable, args) => new ProcessResult( + $"{executable} {string.Join(' ', args)}", + 0, + "Restored bv.\nRestored ngbv.", + string.Empty, + TimeSpan.Zero), }; var output = new StringWriter(); var service = CreateService(runner, output); @@ -324,7 +340,12 @@ public async Task TryDelegate_WhenRestoreFails_WarnsAndRunsAnyway() { using var home = CreateDelegatableHome("2.1.40-preview"); var runner = new FakeProcessRunner { - OnRun = static (executable, args) => new ProcessResult($"{executable} {string.Join(' ', args)}", 1, string.Empty, "simulated failure", TimeSpan.Zero), + OnRun = static (executable, args) => new ProcessResult( + $"{executable} {string.Join(' ', args)}", + 1, + string.Empty, + "simulated failure", + TimeSpan.Zero), OnRunWithInheritedStdio = static (_, _) => 42, }; var output = new StringWriter(); @@ -412,5 +433,6 @@ private static TempHome CreateDelegatableHome(string pinnedVersion) return home; } - private static void WriteToolManifest(TempHome home, string content) => home.WriteFile("dotnet-tools.json", content); + private static void WriteToolManifest(TempHome home, string content) + => home.WriteFile("dotnet-tools.json", content); } diff --git a/tests/Buildvana.Tool.Tests/DependenciesUpdateSettingsTests.cs b/tests/Buildvana.Tool.Tests/DependenciesUpdateSettingsTests.cs index ae470dc4..5267c56f 100644 --- a/tests/Buildvana.Tool.Tests/DependenciesUpdateSettingsTests.cs +++ b/tests/Buildvana.Tool.Tests/DependenciesUpdateSettingsTests.cs @@ -36,8 +36,8 @@ public async Task Parse_ReadsCheckAndAll() [Test] public async Task Parse_WithAllAndNoCheck_IsRefused() { - var exception = await Assert.That(() => DependenciesUpdateSettings.Parse([], ["--all"])).Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.Usage); + var exception = Assert.Throws(() => DependenciesUpdateSettings.Parse([], ["--all"])); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.Usage); } [Test] @@ -57,17 +57,16 @@ public async Task Parse_ReadsTheStatedVersion() [Test] public async Task Parse_WithAVersionThatDoesNotParse_IsRefused() { - var exception = await Assert.That(() => DependenciesUpdateSettings.Parse(["Serilog"], ["--to", "not-a-version"])) - .Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.Usage); + var exception = Assert.Throws(() => DependenciesUpdateSettings.Parse(["Serilog"], ["--to", "not-a-version"])); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.Usage); } [Test] public async Task Parse_WithToAndCheck_IsRefused() { - var exception = await Assert.That(() => DependenciesUpdateSettings.Parse(["Serilog"], ["--to", "3.1.0", "--check"])) - .Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.Usage); + var exception = Assert.Throws( + () => DependenciesUpdateSettings.Parse(["Serilog"], ["--to", "3.1.0", "--check"])); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.Usage); } // Unlike --to, --latest names no version, so it takes patterns and several arguments as a plain run does. @@ -82,40 +81,37 @@ public async Task Parse_ReadsLatestNextToPatterns() [Test] public async Task Parse_WithLatestAndTo_IsRefused() { - var exception = await Assert.That(() => DependenciesUpdateSettings.Parse(["Serilog"], ["--latest", "--to", "3.1.0"])) - .Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.Usage); + var exception = Assert.Throws( + () => DependenciesUpdateSettings.Parse(["Serilog"], ["--latest", "--to", "3.1.0"])); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.Usage); } [Test] public async Task Parse_WithLatestAndCheck_IsRefused() { - var exception = await Assert.That(() => DependenciesUpdateSettings.Parse([], ["--latest", "--check"])) - .Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.Usage); + var exception = Assert.Throws(() => DependenciesUpdateSettings.Parse([], ["--latest", "--check"])); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.Usage); } [Test] public async Task Parse_WithNetSdkAndAnArgument_IsRefused() { - var exception = await Assert.That(() => DependenciesUpdateSettings.Parse(["Serilog"], ["--netsdk"])) - .Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.Usage); + var exception = Assert.Throws(() => DependenciesUpdateSettings.Parse(["Serilog"], ["--netsdk"])); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.Usage); } [Test] public async Task Parse_WithToAndAPattern_IsRefused() { - var exception = await Assert.That(() => DependenciesUpdateSettings.Parse(["Serilog.*"], ["--to", "3.1.0"])) - .Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.Usage); + var exception = Assert.Throws(() => DependenciesUpdateSettings.Parse(["Serilog.*"], ["--to", "3.1.0"])); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.Usage); } [Test] public async Task Parse_WithToAndSeveralArguments_IsRefused() { - var exception = await Assert.That(() => DependenciesUpdateSettings.Parse(["Serilog", "Newtonsoft.Json"], ["--to", "3.1.0"])) - .Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.Usage); + var exception = Assert.Throws( + () => DependenciesUpdateSettings.Parse(["Serilog", "Newtonsoft.Json"], ["--to", "3.1.0"])); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.Usage); } } diff --git a/tests/Buildvana.Tool.Tests/DependencyResolutionTests.cs b/tests/Buildvana.Tool.Tests/DependencyResolutionTests.cs index a8552cd3..028953ee 100644 --- a/tests/Buildvana.Tool.Tests/DependencyResolutionTests.cs +++ b/tests/Buildvana.Tool.Tests/DependencyResolutionTests.cs @@ -44,9 +44,10 @@ public async Task Skipping_WithANetSdkPin_StatesItSkippedAndWritesNothing() var resolution = Skipping(inventory); - await Assert.That(resolution.NetSdk!.Pin).IsEqualTo(inventory.NetSdk); - await Assert.That(resolution.NetSdk.State).IsEqualTo(PinResolutionState.Skipped); - await Assert.That(resolution.NetSdk.WritesAllowPrerelease).IsFalse(); + var netSdk = await Assert.That(resolution.NetSdk).IsNotNull(); + await Assert.That(netSdk.Pin).IsEqualTo(inventory.NetSdk); + await Assert.That(netSdk.State).IsEqualTo(PinResolutionState.Skipped); + await Assert.That(netSdk.WritesAllowPrerelease).IsFalse(); } [Test] diff --git a/tests/Buildvana.Tool.Tests/DependencyResolverTests.cs b/tests/Buildvana.Tool.Tests/DependencyResolverTests.cs index 0d5b7517..6aded477 100644 --- a/tests/Buildvana.Tool.Tests/DependencyResolverTests.cs +++ b/tests/Buildvana.Tool.Tests/DependencyResolverTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using Buildvana.Core; using Buildvana.Runtime; using Buildvana.Tool.Services.Dependencies; @@ -74,7 +75,8 @@ public async Task ResolveAsync_AnIdNoSourceKnows_Fails() var versions = new FakePackageVersionSource(); var exception = await Assert.That(async () => await ResolveAsync(versions, Packages(Pin("Serilog", "3.0.0"))).ConfigureAwait(false)) .Throws(); - var diagnostic = exception!.Diagnostics.Single(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + var diagnostic = exception.Diagnostics.Single(); await Assert.That(diagnostic.Code).IsEqualTo("BV1200"); await Assert.That(diagnostic.File).IsEqualTo("Directory.Packages.props"); } @@ -85,7 +87,8 @@ public async Task ResolveAsync_AVersionNoSourceHas_Fails() var versions = new FakePackageVersionSource().Knows("Serilog", ["3.1.0"]); var exception = await Assert.That(async () => await ResolveAsync(versions, Packages(Pin("Serilog", "3.0.0"))).ConfigureAwait(false)) .Throws(); - await Assert.That(exception!.Diagnostics.Single().Code).IsEqualTo("BV1201"); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + await Assert.That(exception.Diagnostics.Single().Code).IsEqualTo("BV1201"); } [Test] @@ -95,7 +98,8 @@ public async Task ResolveAsync_ReportsEveryPinItCannotResolve() var inventory = Packages(Pin("Serilog", "3.0.0"), Pin("Newtonsoft.Json", "13.0.3")); var exception = await Assert.That(async () => await ResolveAsync(versions, inventory).ConfigureAwait(false)) .Throws(); - await Assert.That(exception!.Diagnostics.Select(static diagnostic => diagnostic.Code)).IsEquivalentTo(["BV1201", "BV1200"]); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + await Assert.That(exception.Diagnostics.Select(static diagnostic => diagnostic.Code)).IsEquivalentTo(["BV1201", "BV1200"]); } [Test] @@ -111,9 +115,10 @@ public async Task ResolveAsync_MovesTheNetSdkWithinItsPolicy() { var releases = new FakeNetSdkReleaseSource().Knows(isLts: true, "10.0.100", "10.0.101", "10.0.201"); var resolution = await ResolveNetSdkAsync(releases, NetSdkPin.Create("10.0.100", allowPrerelease: false)).ConfigureAwait(false); - await Assert.That(resolution.NetSdk!.State).IsEqualTo(PinResolutionState.Updated); - await Assert.That(resolution.NetSdk.Target?.ToNormalizedString()).IsEqualTo("10.0.201"); - await Assert.That(resolution.NetSdk.WritesAllowPrerelease).IsFalse(); + var netSdk = await Assert.That(resolution.NetSdk).IsNotNull(); + await Assert.That(netSdk.State).IsEqualTo(PinResolutionState.Updated); + await Assert.That(netSdk.Target?.ToNormalizedString()).IsEqualTo("10.0.201"); + await Assert.That(netSdk.WritesAllowPrerelease).IsFalse(); } [Test] @@ -121,18 +126,21 @@ public async Task ResolveAsync_WhenAllowPrereleaseDisagreesWithThePolicy_SaysItM { var releases = new FakeNetSdkReleaseSource().Knows(isLts: true, "10.0.100"); var resolution = await ResolveNetSdkAsync(releases, NetSdkPin.Create("10.0.100", allowPrerelease: null)).ConfigureAwait(false); - await Assert.That(resolution.NetSdk!.WritesAllowPrerelease).IsTrue(); - await Assert.That(resolution.NetSdk.Note).IsNotEmpty(); + var netSdk = await Assert.That(resolution.NetSdk).IsNotNull(); + await Assert.That(netSdk.WritesAllowPrerelease).IsTrue(); + await Assert.That(netSdk.Note).IsNotEmpty(); } [Test] public async Task ResolveAsync_ANetSdkVersionTheIndexHasNot_Fails() { var releases = new FakeNetSdkReleaseSource().Knows(isLts: true, "10.0.100"); + var pin = NetSdkPin.Create("10.0.999", allowPrerelease: false); var exception = await Assert - .That(async () => await ResolveNetSdkAsync(releases, NetSdkPin.Create("10.0.999", allowPrerelease: false)).ConfigureAwait(false)) + .That(async () => await ResolveNetSdkAsync(releases, pin).ConfigureAwait(false)) .Throws(); - var diagnostic = exception!.Diagnostics.Single(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + var diagnostic = exception.Diagnostics.Single(); await Assert.That(diagnostic.Code).IsEqualTo("BV1202"); await Assert.That(diagnostic.File).IsEqualTo("global.json"); } @@ -202,7 +210,8 @@ public async Task ResolveAsync_WithAStatedVersionNoSourceHas_Fails() var exception = await Assert .That(async () => await ResolveAsync(versions, Packages(Pin("Serilog", "3.0.0")), request: request).ConfigureAwait(false)) .Throws(); - await Assert.That(exception!.Diagnostics.Select(static diagnostic => diagnostic.Code)).Contains("BV1201"); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + await Assert.That(exception.Diagnostics.Select(static diagnostic => diagnostic.Code)).Contains("BV1201"); } [Test] @@ -213,7 +222,8 @@ public async Task ResolveAsync_WithAStatedVersionForAnIdNoPinHas_Fails() var exception = await Assert .That(async () => await ResolveAsync(versions, Packages(Pin("Serilog", "3.0.0")), request: request).ConfigureAwait(false)) .Throws(); - await Assert.That(exception!.Diagnostics.Single().Code).IsEqualTo("BV1203"); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + await Assert.That(exception.Diagnostics.Single().Code).IsEqualTo("BV1203"); } // A family id with no pin bv manages gets the message that says where to go instead. @@ -225,7 +235,8 @@ public async Task ResolveAsync_WithAStatedVersionForAFamilyId_PointsAtSelfUpdate var exception = await Assert .That(async () => await ResolveAsync(versions, Packages(Pin("Serilog", "3.0.0")), request: request).ConfigureAwait(false)) .Throws(); - await Assert.That(exception!.Diagnostics.Single().Message).Contains("bv self-update"); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + await Assert.That(exception.Diagnostics.Single().Message).Contains("bv self-update"); } // A request for the latest version moves past the kind of the policy, a disabled one included. The pin is @@ -279,9 +290,10 @@ public async Task ResolveAsync_AskedForTheLatest_MovesTheNetSdkPastItsPolicy() var request = new DependencyResolutionRequest { Latest = true }; var pin = NetSdkPin.Create("10.0.100", allowPrerelease: false); var resolution = await ResolveNetSdkAsync(releases, pin, request, config).ConfigureAwait(false); - await Assert.That(resolution.NetSdk!.State).IsEqualTo(PinResolutionState.Updated); - await Assert.That(resolution.NetSdk.Target?.ToNormalizedString()).IsEqualTo("11.0.100"); - await Assert.That(resolution.NetSdk.Policy.ToString()).IsEqualTo("lts"); + var netSdk = await Assert.That(resolution.NetSdk).IsNotNull(); + await Assert.That(netSdk.State).IsEqualTo(PinResolutionState.Updated); + await Assert.That(netSdk.Target?.ToNormalizedString()).IsEqualTo("11.0.100"); + await Assert.That(netSdk.Policy.ToString()).IsEqualTo("lts"); } [Test] @@ -291,8 +303,9 @@ public async Task ResolveAsync_WithAStatedVersionForTheNetSdk_TakesIt() var request = new DependencyResolutionRequest { To = NuGetVersion.Parse("10.0.100") }; var pin = NetSdkPin.Create("10.0.201", allowPrerelease: false); var resolution = await ResolveNetSdkAsync(releases, pin, request).ConfigureAwait(false); - await Assert.That(resolution.NetSdk!.State).IsEqualTo(PinResolutionState.Updated); - await Assert.That(resolution.NetSdk.Target?.ToNormalizedString()).IsEqualTo("10.0.100"); + var netSdk = await Assert.That(resolution.NetSdk).IsNotNull(); + await Assert.That(netSdk.State).IsEqualTo(PinResolutionState.Updated); + await Assert.That(netSdk.Target?.ToNormalizedString()).IsEqualTo("10.0.100"); } [Test] @@ -303,7 +316,8 @@ public async Task ResolveAsync_WithAStatedNetSdkVersionTheIndexHasNot_Fails() var pin = NetSdkPin.Create("10.0.100", allowPrerelease: false); var exception = await Assert.That(async () => await ResolveNetSdkAsync(releases, pin, request).ConfigureAwait(false)) .Throws(); - await Assert.That(exception!.Diagnostics.Single().Code).IsEqualTo("BV1202"); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + await Assert.That(exception.Diagnostics.Single().Code).IsEqualTo("BV1202"); } private static DependencyPin Pin(string id, string versionText) diff --git a/tests/Buildvana.Tool.Tests/DependencyScopeSelectionTests.cs b/tests/Buildvana.Tool.Tests/DependencyScopeSelectionTests.cs index 4c197341..ffee96f1 100644 --- a/tests/Buildvana.Tool.Tests/DependencyScopeSelectionTests.cs +++ b/tests/Buildvana.Tool.Tests/DependencyScopeSelectionTests.cs @@ -39,10 +39,9 @@ public async Task Narrow_WithAVersionForTheNetSdkAlone_ChangesNothing() [Test] public async Task Narrow_WithAVersionAndAnotherScopeSelected_IsRefused() { - var exception = await Assert - .That(() => DependencyScopeSelection.Narrow(AllScopes.ToHashSet(), namesPins: false, statesVersion: true)) - .Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.Usage); + var exception = Assert.Throws( + () => DependencyScopeSelection.Narrow(AllScopes.ToHashSet(), namesPins: false, statesVersion: true)); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.Usage); } [Test] @@ -78,9 +77,8 @@ public async Task Resolve_WithScopesExcluded_TakesTheRest() [Test] public async Task Resolve_MixingTheTwoFamilies_IsAUsageError() { - var exception = await Assert.That(() => Resolve([DependencyScope.NetSdk], [DependencyScope.Packages])) - .Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(2); + var exception = Assert.Throws(() => Resolve([DependencyScope.NetSdk], [DependencyScope.Packages])); + await Assert.That(exception.ExitCode).IsEqualTo(2); } [Test] diff --git a/tests/Buildvana.Tool.Tests/DirectivePinReaderTests.cs b/tests/Buildvana.Tool.Tests/DirectivePinReaderTests.cs index adc9640e..06c6762b 100644 --- a/tests/Buildvana.Tool.Tests/DirectivePinReaderTests.cs +++ b/tests/Buildvana.Tool.Tests/DirectivePinReaderTests.cs @@ -21,7 +21,7 @@ internal sealed class DirectivePinReaderTests public async Task Read_StatesOnePinPerVersionedDirective() { using var home = new TempHome(); - Write(home, "tools/report.cs", ToolApp); + home.WriteFile("tools/report.cs", ToolApp); var pins = Read(home); await Assert.That(pins.Select(static pin => pin.Scope + " " + pin.Id + " " + pin.VersionText)) .IsEquivalentTo(["Packages Serilog 4.0.0", "Sdks Microsoft.Build.NoTargets 3.7.0"]); @@ -33,7 +33,7 @@ await Assert.That(pins.Select(static pin => pin.Scope + " " + pin.Id + " " + pin public async Task Read_IgnoresAFileOutsideTheScope() { using var home = new TempHome(); - Write(home, "src/App/Program.cs", ToolApp); + home.WriteFile("src/App/Program.cs", ToolApp); await Assert.That(Read(home)).IsEmpty(); } @@ -41,7 +41,7 @@ public async Task Read_IgnoresAFileOutsideTheScope() public async Task Read_ReadsTheBuiltInHooksScope() { using var home = new TempHome(); - Write(home, ".buildvana/hooks/release/post-release.cs", ToolApp); + home.WriteFile(".buildvana/hooks/release/post-release.cs", ToolApp); var pins = Read(home, new BuildvanaConfig()); await Assert.That(pins.Select(static pin => pin.Id)).Contains("Serilog"); } @@ -51,7 +51,7 @@ public async Task Read_ReadsTheBuiltInHooksScope() public async Task Read_LeavesAFamilyDirectiveOut() { using var home = new TempHome(); - Write(home, "tools/report.cs", "#:sdk Buildvana.Sdk@2.1.0\n#:package Buildvana.Runtime@2.1.0\n"); + home.WriteFile("tools/report.cs", "#:sdk Buildvana.Sdk@2.1.0\n#:package Buildvana.Runtime@2.1.0\n"); await Assert.That(Read(home)).IsEmpty(); } @@ -61,7 +61,7 @@ public async Task Read_LeavesAFamilyDirectiveOut() public async Task Read_StatesADirectiveWithAnEmptyVersionAsUnmanaged() { using var home = new TempHome(); - Write(home, "tools/report.cs", "#:package Serilog@\n"); + home.WriteFile("tools/report.cs", "#:package Serilog@\n"); await Assert.That(Read(home).Single().Management).IsEqualTo(PinManagement.UnreadableVersion); } @@ -71,7 +71,7 @@ public async Task Read_StatesADirectiveWithAnEmptyVersionAsUnmanaged() public async Task Read_StatesAVersionlessPackageDirectiveAsAReference() { using var home = new TempHome(); - Write(home, "tools/report.cs", ToolApp); + home.WriteFile("tools/report.cs", ToolApp); var read = ReadAll(home); await Assert.That(read.References).IsEquivalentTo(["Spectre.Console"]); } @@ -81,8 +81,8 @@ public async Task Read_StatesAVersionlessPackageDirectiveAsAReference() public async Task Read_NamesAReferencedPackageOnce() { using var home = new TempHome(); - Write(home, "tools/first.cs", "#:package Spectre.Console\n"); - Write(home, "tools/second.cs", "#:package spectre.console\n"); + home.WriteFile("tools/first.cs", "#:package Spectre.Console\n"); + home.WriteFile("tools/second.cs", "#:package spectre.console\n"); var read = ReadAll(home); await Assert.That(read.References.Count).IsEqualTo(1); } @@ -91,19 +91,13 @@ public async Task Read_NamesAReferencedPackageOnce() public async Task Read_IgnoresBuildOutput() { using var home = new TempHome(); - Write(home, "tools/obj/generated.cs", ToolApp); + home.WriteFile("tools/obj/generated.cs", ToolApp); await Assert.That(Read(home)).IsEmpty(); } - private static IReadOnlyList Read(TempHome home, BuildvanaConfig? config = null) => ReadAll(home, config).Pins; + private static IReadOnlyList Read(TempHome home, BuildvanaConfig? config = null) + => ReadAll(home, config).Pins; private static DirectivePins ReadAll(TempHome home, BuildvanaConfig? config = null) => new DirectivePinReader(home.Provider, config ?? new BuildvanaConfig { FileBasedApps = ["/tools/"] }).Read(); - - private static void Write(TempHome home, string relativePath, string content) - { - var path = home.GetFullPath(relativePath); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.WriteAllText(path, content); - } } diff --git a/tests/Buildvana.Tool.Tests/EffectivePolicyResolverTests.cs b/tests/Buildvana.Tool.Tests/EffectivePolicyResolverTests.cs index 6573fdf3..7b8075e8 100644 --- a/tests/Buildvana.Tool.Tests/EffectivePolicyResolverTests.cs +++ b/tests/Buildvana.Tool.Tests/EffectivePolicyResolverTests.cs @@ -100,13 +100,14 @@ public async Task Resolve_OfAPinStatingNonsense_Fails() { var resolver = new EffectivePolicyResolver(new DependenciesConfig()); var pin = Pin(DependencyScope.Packages) with { MetadataPolicy = "sometimes" }; - var exception = await Assert.That(() => resolver.Resolve(pin)).Throws(); - await Assert.That(exception!.Message).Contains("sometimes"); + var exception = Assert.Throws(() => resolver.Resolve(pin)); + await Assert.That(exception.Message).Contains("sometimes"); await Assert.That(exception.Message).Contains("Serilog"); await Assert.That(exception.Message).Contains("Directory.Packages.props"); } - private static string Resolve(EffectivePolicyResolver resolver, DependencyPin pin) => resolver.Resolve(pin).ToString(); + private static string Resolve(EffectivePolicyResolver resolver, DependencyPin pin) + => resolver.Resolve(pin).ToString(); private static DependencyPin Pin(DependencyScope scope, string id = "Serilog") => DependencyPin.Create(scope, id, "4.0.0", "Directory.Packages.props"); diff --git a/tests/Buildvana.Tool.Tests/FileBasedAppHelperTests.cs b/tests/Buildvana.Tool.Tests/FileBasedAppHelperTests.cs index 7207fb36..1d7ffc9a 100644 --- a/tests/Buildvana.Tool.Tests/FileBasedAppHelperTests.cs +++ b/tests/Buildvana.Tool.Tests/FileBasedAppHelperTests.cs @@ -49,8 +49,9 @@ public async Task GetArtifactsDirectory_DistinguishesDifferentPaths() var first = Path.Combine(Path.GetTempPath(), "first", "app.cs"); var second = Path.Combine(Path.GetTempPath(), "second", "app.cs"); - await Assert.That(FileBasedAppHelper.GetArtifactsDirectory(first)!) - .IsNotEqualTo(FileBasedAppHelper.GetArtifactsDirectory(second)!); + var firstDirectory = await Assert.That(FileBasedAppHelper.GetArtifactsDirectory(first)).IsNotNull(); + var secondDirectory = await Assert.That(FileBasedAppHelper.GetArtifactsDirectory(second)).IsNotNull(); + await Assert.That(firstDirectory).IsNotEqualTo(secondDirectory); } [Test] @@ -84,7 +85,7 @@ public async Task GetArtifactsDirectory_AgreesWithInstalledSdk(CancellationToken RedirectStandardError = true, UseShellExecute = false, }; - using var process = Process.Start(startInfo)!; + using var process = await Assert.That(Process.Start(startInfo)).IsNotNull(); var output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false); await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); diff --git a/tests/Buildvana.Tool.Tests/ForgejoApiClientTests.cs b/tests/Buildvana.Tool.Tests/ForgejoApiClientTests.cs index 88941739..9f91e5b1 100644 --- a/tests/Buildvana.Tool.Tests/ForgejoApiClientTests.cs +++ b/tests/Buildvana.Tool.Tests/ForgejoApiClientTests.cs @@ -201,9 +201,9 @@ public async Task Operation_WhenTheApiRefuses_FailsWithTheStatusAndTheMessage(st using var client = NewClient(AnsweringEverything(new StubHttpAnswer(HttpStatusCode.Conflict, body))); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning - var exception = await Assert.That(async () => await InvokeAsync(client, operation).ConfigureAwait(false)) - .Throws(); - await Assert.That(exception!.Message).Contains("409 Conflict: release 1.0.0 already exists"); + await Assert.That(async () => await InvokeAsync(client, operation).ConfigureAwait(false)) + .Throws() + .WithMessageContaining("409 Conflict: release 1.0.0 already exists", StringComparison.Ordinal); } [Test] @@ -220,9 +220,9 @@ public async Task Operation_WhenTheApiAnswersNotFoundWithErrors_FailsWithTheMess using var client = NewClient(AnsweringEverything(new StubHttpAnswer(HttpStatusCode.NotFound, body))); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning - var exception = await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) - .Throws(); - await Assert.That(exception!.Message).Contains("404 Not Found: The target couldn't be found."); + await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) + .Throws() + .WithMessageContaining("404 Not Found: The target couldn't be found.", StringComparison.Ordinal); } [Test] @@ -235,14 +235,16 @@ public async Task Operation_WhenTheErrorAnswerStatesNoMessage_FailsWithTheStatus using var client = NewClient(AnsweringEverything(new StubHttpAnswer(HttpStatusCode.BadGateway, body))); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning - var exception = await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) - .Throws(); - await Assert.That(exception!.Message).EndsWith("with 502 Bad Gateway."); + await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) + .Throws() + .WithMessageMatching("*with 502 Bad Gateway."); } - private static ForgejoApiClient NewClient(StubHttpMessageHandler handler) => new("forgejo.example.com", Token, handler); + private static ForgejoApiClient NewClient(StubHttpMessageHandler handler) + => new("forgejo.example.com", Token, handler); - private static StubHttpMessageHandler Answering() => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>()); + private static StubHttpMessageHandler Answering() + => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>()); private static StubHttpMessageHandler AnsweringEverything(StubHttpAnswer answer) => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>(), answer); diff --git a/tests/Buildvana.Tool.Tests/ForgejoHostTests.cs b/tests/Buildvana.Tool.Tests/ForgejoHostTests.cs index e5a124ee..c9a539e6 100644 --- a/tests/Buildvana.Tool.Tests/ForgejoHostTests.cs +++ b/tests/Buildvana.Tool.Tests/ForgejoHostTests.cs @@ -76,9 +76,9 @@ public async Task Constructor_ForAPathOfThreeSegments_Throws() await using (services.ConfigureAwait(false)) { // ReSharper disable once AccessToDisposedClosure // False positive: the assertion runs the lambda before services is disposed - var exception = await Assert.That(() => new ForgejoHost(services, Parse("https://codeberg.org/a/b/c"))) - .Throws(); - await Assert.That(exception!.Message).Contains("not a valid Forgejo repository URL"); + await Assert.That(() => new ForgejoHost(services, Parse("https://codeberg.org/a/b/c"))) + .Throws() + .WithMessageContaining("not a valid Forgejo repository URL", StringComparison.Ordinal); } } @@ -127,8 +127,9 @@ public async Task IsPrivateRepositoryAsync_WithoutAToken_Throws() Environment.SetEnvironmentVariable(TokenVariable, null); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before host is disposed - var exception = await Assert.That(host.IsPrivateRepositoryAsync).Throws(); - await Assert.That(exception!.Message).Contains(TokenVariable); + await Assert.That(host.IsPrivateRepositoryAsync) + .Throws() + .WithMessageContaining(TokenVariable, StringComparison.Ordinal); await Assert.That(api.Requests).IsEmpty(); } } diff --git a/tests/Buildvana.Tool.Tests/GitHostTests.cs b/tests/Buildvana.Tool.Tests/GitHostTests.cs index 62609f91..8b07aef4 100644 --- a/tests/Buildvana.Tool.Tests/GitHostTests.cs +++ b/tests/Buildvana.Tool.Tests/GitHostTests.cs @@ -102,15 +102,17 @@ public async Task DetectKind_ForAnotherHost_IsNull() [Arguments("")] public async Task ParseRepositoryUrl_WithoutAUrl_Throws(string? url) { - var exception = await Assert.That(() => GitHost.ParseRepositoryUrl(Repository(url))).Throws(); - await Assert.That(exception!.Message).Contains("repository.url"); + await Assert.That(() => GitHost.ParseRepositoryUrl(Repository(url))) + .Throws() + .WithMessageContaining("repository.url", StringComparison.Ordinal); } [Test] public async Task ParseRepositoryUrl_WithAnInvalidUrl_Throws() { - var exception = await Assert.That(() => GitHost.ParseRepositoryUrl(Repository("not a URL"))).Throws(); - await Assert.That(exception!.Message).Contains("not a Git URL"); + await Assert.That(() => GitHost.ParseRepositoryUrl(Repository("not a URL"))) + .Throws() + .WithMessageContaining("not a Git URL", StringComparison.Ordinal); } [Test] @@ -176,8 +178,9 @@ public async Task Create_ForAUrlWithOneSegment_Throws() await using (services.ConfigureAwait(false)) { // ReSharper disable once AccessToDisposedClosure // False positive: the lambda runs before services is disposed - var exception = await Assert.That(() => GitHost.Create(services)).Throws(); - await Assert.That(exception!.Message).Contains("names no repository"); + await Assert.That(() => GitHost.Create(services)) + .Throws() + .WithMessageContaining("names no repository", StringComparison.Ordinal); } } diff --git a/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs b/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs index 993f8fac..edff0564 100644 --- a/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs +++ b/tests/Buildvana.Tool.Tests/GitHubApiClientTests.cs @@ -262,9 +262,9 @@ public async Task Operation_WhenTheApiRefuses_FailsWithTheStatusAndTheMessage(st new StubHttpAnswer(HttpStatusCode.UnprocessableEntity, """{"message":"Validation Failed"}"""))); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning - var exception = await Assert.That(async () => await InvokeAsync(client, operation).ConfigureAwait(false)) - .Throws(); - await Assert.That(exception!.Message).Contains("422 Unprocessable Entity: Validation Failed"); + await Assert.That(async () => await InvokeAsync(client, operation).ConfigureAwait(false)) + .Throws() + .WithMessageContaining("422 Unprocessable Entity: Validation Failed", StringComparison.Ordinal); } [Test] @@ -276,9 +276,9 @@ public async Task Operation_WhenTheErrorAnswerStatesNoMessage_FailsWithTheStatus using var client = NewClient(AnsweringEverything(new StubHttpAnswer(HttpStatusCode.BadGateway, body))); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning - var exception = await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) - .Throws(); - await Assert.That(exception!.Message).EndsWith("with 502 Bad Gateway."); + await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) + .Throws() + .WithMessageMatching("*with 502 Bad Gateway."); } [Test] @@ -329,7 +329,8 @@ await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo, cance private static GitHubApiClient NewClient(StubHttpMessageHandler handler) => new("github.com", Token, handler); - private static StubHttpMessageHandler Answering() => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>()); + private static StubHttpMessageHandler Answering() + => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>()); private static StubHttpMessageHandler AnsweringEverything(StubHttpAnswer answer) => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>(), answer); diff --git a/tests/Buildvana.Tool.Tests/GitHubHostTests.cs b/tests/Buildvana.Tool.Tests/GitHubHostTests.cs index 10652df8..4b27425e 100644 --- a/tests/Buildvana.Tool.Tests/GitHubHostTests.cs +++ b/tests/Buildvana.Tool.Tests/GitHubHostTests.cs @@ -59,9 +59,10 @@ public async Task Constructor_ForAPathOfThreeSegments_Throws() var services = Services(); await using (services.ConfigureAwait(false)) { - // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before services is disposed - var exception = await Assert.That(() => new GitHubHost(services, Parse("https://github.com/a/b/c"))).Throws(); - await Assert.That(exception!.Message).Contains("not a valid GitHub repository URL"); + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion runs the lambda before services is disposed + await Assert.That(() => new GitHubHost(services, Parse("https://github.com/a/b/c"))) + .Throws() + .WithMessageContaining("not a valid GitHub repository URL", StringComparison.Ordinal); } } @@ -102,13 +103,15 @@ public async Task IsPrivateRepositoryAsync_WithoutAToken_Throws() Environment.SetEnvironmentVariable(TokenVariable, null); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before host is disposed - var exception = await Assert.That(host.IsPrivateRepositoryAsync).Throws(); - await Assert.That(exception!.Message).Contains(TokenVariable); + await Assert.That(host.IsPrivateRepositoryAsync) + .Throws() + .WithMessageContaining(TokenVariable, StringComparison.Ordinal); await Assert.That(api.Requests).IsEmpty(); } } - private static GitUrlInfo Parse(string url) => GitUrlInfo.TryCreate(url, out var info) ? info : throw new InvalidOperationException(url); + private static GitUrlInfo Parse(string url) + => GitUrlInfo.TryCreate(url, out var info) ? info : throw new InvalidOperationException(url); private static ServiceProvider Services() => new ServiceCollection() diff --git a/tests/Buildvana.Tool.Tests/GitLabApiClientTests.cs b/tests/Buildvana.Tool.Tests/GitLabApiClientTests.cs index a843121b..8e93204a 100644 --- a/tests/Buildvana.Tool.Tests/GitLabApiClientTests.cs +++ b/tests/Buildvana.Tool.Tests/GitLabApiClientTests.cs @@ -316,9 +316,9 @@ public async Task Operation_WhenTheApiRefuses_FailsWithTheStatusAndTheMessage(st new StubHttpAnswer(HttpStatusCode.Forbidden, """{"message":"403 Forbidden"}"""))); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning - var exception = await Assert.That(async () => await InvokeAsync(client, operation).ConfigureAwait(false)) - .Throws(); - await Assert.That(exception!.Message).Contains("403 Forbidden: 403 Forbidden"); + await Assert.That(async () => await InvokeAsync(client, operation).ConfigureAwait(false)) + .Throws() + .WithMessageContaining("403 Forbidden: 403 Forbidden", StringComparison.Ordinal); } [Test] @@ -332,9 +332,9 @@ public async Task Operation_WhenTheApiRefuses_QuotesTheMessageWhateverItsShape(s using var client = NewClient(AnsweringEverything(new StubHttpAnswer(HttpStatusCode.BadRequest, body))); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning - var exception = await Assert.That(async () => await client.GetProjectAsync(Project).ConfigureAwait(false)) - .Throws(); - await Assert.That(exception!.Message).EndsWith("with 400 Bad Request" + expected); + await Assert.That(async () => await client.GetProjectAsync(Project).ConfigureAwait(false)) + .Throws() + .WithMessageMatching("*with 400 Bad Request" + expected); } [Test] @@ -349,9 +349,9 @@ public async Task Operation_WhenTheErrorAnswerStatesNoMessage_FailsWithTheStatus using var client = NewClient(AnsweringEverything(new StubHttpAnswer(HttpStatusCode.BadGateway, body))); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning - var exception = await Assert.That(async () => await client.GetProjectAsync(Project).ConfigureAwait(false)) - .Throws(); - await Assert.That(exception!.Message).EndsWith("with 502 Bad Gateway."); + await Assert.That(async () => await client.GetProjectAsync(Project).ConfigureAwait(false)) + .Throws() + .WithMessageMatching("*with 502 Bad Gateway."); } [Test] @@ -379,7 +379,8 @@ await Assert.That(async () => await client.GetProjectAsync(Project).ConfigureAwa private static GitLabApiClient NewClient(StubHttpMessageHandler handler) => new("gitlab.com", Token, GitLabTokenKind.AccessToken, handler); - private static StubHttpMessageHandler Answering() => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>()); + private static StubHttpMessageHandler Answering() + => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>()); private static StubHttpMessageHandler AnsweringEverything(StubHttpAnswer answer) => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>(), answer); diff --git a/tests/Buildvana.Tool.Tests/GitLabHostTests.cs b/tests/Buildvana.Tool.Tests/GitLabHostTests.cs index b1aa03c3..c71072fb 100644 --- a/tests/Buildvana.Tool.Tests/GitLabHostTests.cs +++ b/tests/Buildvana.Tool.Tests/GitLabHostTests.cs @@ -166,13 +166,15 @@ public async Task IsPrivateRepositoryAsync_WithoutAToken_Throws() Environment.SetEnvironmentVariable(TokenVariable, null); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before host is disposed - var exception = await Assert.That(host.IsPrivateRepositoryAsync).Throws(); - await Assert.That(exception!.Message).Contains(TokenVariable); + await Assert.That(host.IsPrivateRepositoryAsync) + .Throws() + .WithMessageContaining(TokenVariable, StringComparison.Ordinal); await Assert.That(api.Requests).IsEmpty(); } } - private static GitUrlInfo Parse(string url) => GitUrlInfo.TryCreate(url, out var info) ? info : throw new InvalidOperationException(url); + private static GitUrlInfo Parse(string url) + => GitUrlInfo.TryCreate(url, out var info) ? info : throw new InvalidOperationException(url); private static ServiceProvider Services(string tokenEnv) => new ServiceCollection() diff --git a/tests/Buildvana.Tool.Tests/GitLabReleasePublisherTests.cs b/tests/Buildvana.Tool.Tests/GitLabReleasePublisherTests.cs index b93ad526..07d606d8 100644 --- a/tests/Buildvana.Tool.Tests/GitLabReleasePublisherTests.cs +++ b/tests/Buildvana.Tool.Tests/GitLabReleasePublisherTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using System.Net; using Buildvana.Core; using Buildvana.Core.ConsoleOutput; @@ -45,10 +46,9 @@ public async Task BeginAsync_BelowTheMaintainerRole_Throws() using var api = Answering(Member(30)); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before api is disposed - var exception = await Assert.That(() => RunAsync(api, static (publisher, _) => publisher.BeginAsync())) - .Throws(); - - await Assert.That(exception!.Message).Contains("access level 30 on tenacom/buildvana, below the Maintainer role"); + await Assert.That(() => RunAsync(api, static (publisher, _) => publisher.BeginAsync())) + .Throws() + .WithMessageContaining("access level 30 on tenacom/buildvana, below the Maintainer role", StringComparison.Ordinal); } // An instance administrator, or an organization owner, has no membership and can still delete a package. @@ -86,10 +86,9 @@ public async Task BeginAsync_WhenTheReleasesCannotBeRead_Throws_AndReadsNothingE }); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before api is disposed - var exception = await Assert.That(() => RunAsync(api, static (publisher, _) => publisher.BeginAsync())) - .Throws(); - - await Assert.That(exception!.Message).Contains("403 Forbidden"); + await Assert.That(() => RunAsync(api, static (publisher, _) => publisher.BeginAsync())) + .Throws() + .WithMessageContaining("403 Forbidden", StringComparison.Ordinal); await Assert.That(api.Requests).Count().IsEqualTo(1); } @@ -215,9 +214,10 @@ public async Task CheckAssets_ReportsEveryAssetGitLabRefuses() // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before api is disposed var exception = await Assert.That(() => RunAsync(api, (publisher, _) => CheckAssetsAsync(publisher, assets))) .Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); await Assert.That(api.Requests).IsEmpty(); - await Assert.That(exception!.Message).IsEqualTo( + await Assert.That(exception.Message).IsEqualTo( "GitLab refuses the release assets:" + "\n- 'Foo 1.zip' is not a valid file name in the generic package registry." + "\n- 'a+b.zip' is not a valid file name for a release link." @@ -318,5 +318,6 @@ private static async Task RunAsync( } } - private static GitUrlInfo Parse(string url) => GitUrlInfo.TryCreate(url, out var info) ? info : throw new InvalidOperationException(url); + private static GitUrlInfo Parse(string url) + => GitUrlInfo.TryCreate(url, out var info) ? info : throw new InvalidOperationException(url); } diff --git a/tests/Buildvana.Tool.Tests/GitServiceTests.cs b/tests/Buildvana.Tool.Tests/GitServiceTests.cs index 74bd0342..834c0b9b 100644 --- a/tests/Buildvana.Tool.Tests/GitServiceTests.cs +++ b/tests/Buildvana.Tool.Tests/GitServiceTests.cs @@ -7,6 +7,7 @@ using Buildvana.Core.Testing; using Buildvana.Tool.Infrastructure; using Buildvana.Tool.Services.Git; +using NuGet.Versioning; internal sealed class GitServiceTests { @@ -115,7 +116,7 @@ public async Task TrackChangesAsync_WhenOperationThrows_Propagates() using var repo = CreateRepoWithCommit(); using var git = CreateGitService(repo); - // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the enclosing scope disposes git + // ReSharper disable once AccessToDisposedClosure // False positive: the assertion runs Act before the enclosing scope disposes git Task<(bool Result, IReadOnlyList ChangedFiles)> Act() => git.TrackChangesAsync(() => throw new BuildFailedException("Operation failed.")); @@ -147,6 +148,56 @@ public async Task Constructor_WithoutRemotes_Succeeds() await Assert.That(git.Remotes).IsEmpty(); } + [Test] + public async Task GetLatestVersions_WalksHistory_UntilAStableVersion() + { + using var repo = CreateRepoWithCommit(); + repo.CreateTag("1.0.0"); + repo.WriteFile("a.txt", "changed"); + repo.CommitAll(); + repo.CreateTag("1.1.0-preview"); + repo.CreateTag("not-a-version"); + using var git = CreateGitService(repo); + + var (latest, latestStable) = git.GetLatestVersions(); + + await Assert.That(latest).IsEqualTo(SemanticVersion.Parse("1.1.0-preview")); + await Assert.That(latestStable).IsEqualTo(SemanticVersion.Parse("1.0.0")); + } + + // One commit carries three stable tags and three prerelease tags. The highest version of each kind is neither the + // first nor the last, in name order and in creation order. + [Test] + public async Task GetLatestVersions_WithSeveralVersionTagsOnOneCommit_ReadsTheHighest() + { + using var repo = CreateRepoWithCommit(); + repo.CreateTag("1.9.0"); + repo.CreateTag("1.11.0"); + repo.CreateTag("1.10.0"); + repo.CreateTag("2.9.0-preview"); + repo.CreateTag("2.11.0-preview"); + repo.CreateTag("2.10.0-preview"); + using var git = CreateGitService(repo); + + var (latest, latestStable) = git.GetLatestVersions(); + + await Assert.That(latest).IsEqualTo(SemanticVersion.Parse("2.11.0-preview")); + await Assert.That(latestStable).IsEqualTo(SemanticVersion.Parse("1.11.0")); + } + + [Test] + public async Task GetLatestVersions_WithoutVersionTags_ReturnsNulls() + { + using var repo = CreateRepoWithCommit(); + repo.CreateTag("not-a-version"); + using var git = CreateGitService(repo); + + var (latest, latestStable) = git.GetLatestVersions(); + + await Assert.That(latest).IsNull(); + await Assert.That(latestStable).IsNull(); + } + [Test] public async Task CommitterIdentity_WhenSet_CommitsWithIt_AndWritesNothingToConfiguration() { @@ -202,8 +253,9 @@ public async Task PushBranch_WhenNotFastForward_Throws() var remoteTip = repo.GetRemoteTipSha(); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed - var exception = await Assert.That(() => git.PushBranch("origin")).Throws(); - await Assert.That(exception!.Message).Contains($"'refs/heads/{repo.CurrentBranchName}'"); + await Assert.That(() => git.PushBranch("origin")) + .Throws() + .WithMessageContaining($"'refs/heads/{repo.CurrentBranchName}'", StringComparison.Ordinal); await Assert.That(repo.GetRemoteTipSha()).IsEqualTo(remoteTip); } } @@ -233,8 +285,9 @@ public async Task PushBranch_WhenRemoteRefusesUpdate_Throws_AndLeavesRemoteBranc repo.LockRemoteReference(branchRef); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed - var exception = await Assert.That(() => git.PushBranch("origin")).Throws(); - await Assert.That(exception!.Message).Contains(branchRef); + await Assert.That(() => git.PushBranch("origin")) + .Throws() + .WithMessageContaining(branchRef, StringComparison.Ordinal); await Assert.That(repo.GetRemoteTipSha()).IsEqualTo(remoteTip); } @@ -245,8 +298,8 @@ public async Task PushBranch_WhenRemoteIsUnreachable_Throws() using var git = CreateGitService(repo); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed - var exception = await Assert.That(() => git.PushBranch("origin")).Throws(); - await Assert.That(exception!.Message).Contains("'origin'"); + var exception = Assert.Throws(() => git.PushBranch("origin")); + await Assert.That(exception.Message).Contains("'origin'"); await Assert.That(exception.InnerException).IsNotNull(); } @@ -268,8 +321,9 @@ public async Task PushBranch_ToMissingRemote_Throws() using var git = CreateGitService(repo); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed - var exception = await Assert.That(() => git.PushBranch("upstream")).Throws(); - await Assert.That(exception!.Message).Contains("'upstream'"); + await Assert.That(() => git.PushBranch("upstream")) + .Throws() + .WithMessageContaining("'upstream'", StringComparison.Ordinal); } [Test] @@ -320,8 +374,9 @@ public async Task PushTag_WhenRemoteTagPointsToAncestor_Throws_AndLeavesRemoteTa git.CreateTag("1.0.0", repo.HeadSha); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed - var exception = await Assert.That(() => git.PushTag("origin", "1.0.0")).Throws(); - await Assert.That(exception!.Message).Contains(firstSha); + await Assert.That(() => git.PushTag("origin", "1.0.0")) + .Throws() + .WithMessageContaining(firstSha, StringComparison.Ordinal); await Assert.That(repo.GetRemoteTagSha("1.0.0")).IsEqualTo(firstSha); } @@ -361,8 +416,9 @@ public async Task PushTag_WhenRemoteRefusesUpdate_Throws() repo.LockRemoteReference("refs/tags/1.0.0"); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed - var exception = await Assert.That(() => git.PushTag("origin", "1.0.0")).Throws(); - await Assert.That(exception!.Message).Contains("refs/tags/1.0.0"); + await Assert.That(() => git.PushTag("origin", "1.0.0")) + .Throws() + .WithMessageContaining("refs/tags/1.0.0", StringComparison.Ordinal); await Assert.That(repo.GetRemoteTagSha("1.0.0")).IsNull(); } @@ -374,8 +430,8 @@ public async Task PushTag_WhenRemoteIsUnreachable_Throws() git.CreateTag("1.0.0", repo.HeadSha); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed - var exception = await Assert.That(() => git.PushTag("origin", "1.0.0")).Throws(); - await Assert.That(exception!.Message).Contains("'origin'"); + var exception = Assert.Throws(() => git.PushTag("origin", "1.0.0")); + await Assert.That(exception.Message).Contains("'origin'"); await Assert.That(exception.InnerException).IsNotNull(); } @@ -405,8 +461,9 @@ public async Task DeleteRemoteTag_WhenRemoteRefusesUpdate_Throws_AndKeepsRemoteT repo.LockRemoteReference("refs/tags/1.0.0"); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed - var exception = await Assert.That(() => git.DeleteRemoteTag("origin", "1.0.0")).Throws(); - await Assert.That(exception!.Message).Contains("refs/tags/1.0.0"); + await Assert.That(() => git.DeleteRemoteTag("origin", "1.0.0")) + .Throws() + .WithMessageContaining("refs/tags/1.0.0", StringComparison.Ordinal); await Assert.That(repo.GetRemoteTagSha("1.0.0")).IsEqualTo(repo.HeadSha); } @@ -444,8 +501,8 @@ public async Task FetchTags_WhenRemoteIsUnreachable_Throws() using var git = CreateGitService(repo); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before git is disposed - var exception = await Assert.That(() => git.FetchTags("origin")).Throws(); - await Assert.That(exception!.Message).Contains("'origin'"); + var exception = Assert.Throws(() => git.FetchTags("origin")); + await Assert.That(exception.Message).Contains("'origin'"); await Assert.That(exception.InnerException).IsNotNull(); } diff --git a/tests/Buildvana.Tool.Tests/GiteaApiClientTests.cs b/tests/Buildvana.Tool.Tests/GiteaApiClientTests.cs index 0c0aab58..9323eefd 100644 --- a/tests/Buildvana.Tool.Tests/GiteaApiClientTests.cs +++ b/tests/Buildvana.Tool.Tests/GiteaApiClientTests.cs @@ -200,9 +200,9 @@ public async Task Operation_WhenTheApiRefuses_FailsWithTheStatusAndTheMessage(st using var client = NewClient(AnsweringEverything(new StubHttpAnswer(HttpStatusCode.Conflict, body))); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning - var exception = await Assert.That(async () => await InvokeAsync(client, operation).ConfigureAwait(false)) - .Throws(); - await Assert.That(exception!.Message).Contains("409 Conflict: release 1.0.0 already exists"); + await Assert.That(async () => await InvokeAsync(client, operation).ConfigureAwait(false)) + .Throws() + .WithMessageContaining("409 Conflict: release 1.0.0 already exists", StringComparison.Ordinal); } [Test] @@ -215,14 +215,15 @@ public async Task Operation_WhenTheErrorAnswerStatesNoMessage_FailsWithTheStatus using var client = NewClient(AnsweringEverything(new StubHttpAnswer(HttpStatusCode.BadGateway, body))); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning - var exception = await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) - .Throws(); - await Assert.That(exception!.Message).EndsWith("with 502 Bad Gateway."); + await Assert.That(async () => await client.GetRepositoryAsync(Owner, Repo).ConfigureAwait(false)) + .Throws() + .WithMessageMatching("*with 502 Bad Gateway."); } private static GiteaApiClient NewClient(StubHttpMessageHandler handler) => new("gitea.example.com", Token, handler); - private static StubHttpMessageHandler Answering() => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>()); + private static StubHttpMessageHandler Answering() + => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>()); private static StubHttpMessageHandler AnsweringEverything(StubHttpAnswer answer) => new(new Dictionary<(HttpMethod Method, string Url), StubHttpAnswer>(), answer); diff --git a/tests/Buildvana.Tool.Tests/GiteaHostTests.cs b/tests/Buildvana.Tool.Tests/GiteaHostTests.cs index 3f82fd51..a12f8674 100644 --- a/tests/Buildvana.Tool.Tests/GiteaHostTests.cs +++ b/tests/Buildvana.Tool.Tests/GiteaHostTests.cs @@ -75,9 +75,9 @@ public async Task Constructor_ForAPathOfThreeSegments_Throws() await using (services.ConfigureAwait(false)) { // ReSharper disable once AccessToDisposedClosure // False positive: the assertion runs the lambda before services is disposed - var exception = await Assert.That(() => new GiteaHost(services, Parse("https://gitea.com/a/b/c"))) - .Throws(); - await Assert.That(exception!.Message).Contains("not a valid Gitea repository URL"); + await Assert.That(() => new GiteaHost(services, Parse("https://gitea.com/a/b/c"))) + .Throws() + .WithMessageContaining("not a valid Gitea repository URL", StringComparison.Ordinal); } } @@ -126,8 +126,9 @@ public async Task IsPrivateRepositoryAsync_WithoutAToken_Throws() Environment.SetEnvironmentVariable(TokenVariable, null); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before host is disposed - var exception = await Assert.That(host.IsPrivateRepositoryAsync).Throws(); - await Assert.That(exception!.Message).Contains(TokenVariable); + await Assert.That(host.IsPrivateRepositoryAsync) + .Throws() + .WithMessageContaining(TokenVariable, StringComparison.Ordinal); await Assert.That(api.Requests).IsEmpty(); } } diff --git a/tests/Buildvana.Tool.Tests/GlobalJsonPinReaderTests.cs b/tests/Buildvana.Tool.Tests/GlobalJsonPinReaderTests.cs index b985e207..0039ffae 100644 --- a/tests/Buildvana.Tool.Tests/GlobalJsonPinReaderTests.cs +++ b/tests/Buildvana.Tool.Tests/GlobalJsonPinReaderTests.cs @@ -26,7 +26,7 @@ public async Task Read_StatesTheNetSdkBaselineAndItsPrereleaseSetting() """; using var home = new TempHome(); Write(home, content); - var netSdk = CreateReader(home).Read().NetSdk!; + var netSdk = await Assert.That(CreateReader(home).Read().NetSdk).IsNotNull(); await Assert.That(netSdk.VersionText).IsEqualTo("10.0.100"); await Assert.That(netSdk.Version?.ToNormalizedString()).IsEqualTo("10.0.100"); await Assert.That(netSdk.AllowPrerelease).IsTrue(); @@ -39,7 +39,8 @@ public async Task Read_WithNoPrereleaseSetting_LeavesItUnstated() { using var home = new TempHome(); Write(home, """{ "sdk": { "version": "10.0.100" } }"""); - await Assert.That(CreateReader(home).Read().NetSdk!.AllowPrerelease).IsNull(); + var netSdk = await Assert.That(CreateReader(home).Read().NetSdk).IsNotNull(); + await Assert.That(netSdk.AllowPrerelease).IsNull(); } [Test] diff --git a/tests/Buildvana.Tool.Tests/HookRunnerTests.cs b/tests/Buildvana.Tool.Tests/HookRunnerTests.cs index c12204fe..81278389 100644 --- a/tests/Buildvana.Tool.Tests/HookRunnerTests.cs +++ b/tests/Buildvana.Tool.Tests/HookRunnerTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using System.Text.Json; using Buildvana.Core; using Buildvana.Core.ConsoleOutput; @@ -57,8 +58,7 @@ public async Task RunHookAsync_WritesCamelCaseArgsFileAtWellKnownPath() _ = await runner.RunHookAsync(args).ConfigureAwait(false); - await Assert.That(argsJson).IsNotNull(); - using var document = JsonDocument.Parse(argsJson!); + using var document = JsonDocument.Parse(await Assert.That(argsJson).IsNotNull()); var root = document.RootElement; var runtimeInfo = root.GetProperty("runtimeInfo"); await Assert.That(runtimeInfo.GetProperty("version").GetString()).IsEqualTo("1.2.3-preview"); @@ -149,7 +149,8 @@ public async Task RunHookAsync_WhenACheckRunsHookFails_Fails() Task Act() => runner.RunHookAsync(args, acceptsPendingWork: true); var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.ExternalProgramFailed); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.ExternalProgramFailed); } [Test] @@ -180,8 +181,7 @@ public async Task RunHookAsync_WhenArgsFileCannotBeWritten_FailsWithCleanError() Task Act() => runner.RunHookAsync(args); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Could not write to"); + await Assert.That(Act).Throws().WithMessageContaining("Could not write to", StringComparison.Ordinal); await Assert.That(appRunner.Runs.Count).IsEqualTo(0); } diff --git a/tests/Buildvana.Tool.Tests/OrphanDetectorTests.cs b/tests/Buildvana.Tool.Tests/OrphanDetectorTests.cs index db328c8a..67fffcd6 100644 --- a/tests/Buildvana.Tool.Tests/OrphanDetectorTests.cs +++ b/tests/Buildvana.Tool.Tests/OrphanDetectorTests.cs @@ -1,12 +1,14 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using Buildvana.Core; using Buildvana.Core.Dependencies; using Buildvana.Core.Testing; using Buildvana.Tool.Services; using Buildvana.Tool.Services.Dependencies; using Buildvana.Tool.Services.Solution; +using Microsoft.VisualStudio.SolutionPersistence.Model; internal sealed class OrphanDetectorTests { @@ -162,8 +164,9 @@ public async Task DetectAsync_WithARestoreThatFailedForAReasonOfItsOwn_ReportsAF // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning var exception = await Assert.That(async () => await DetectAsync(Inventory(home, ["Alpha"])).ConfigureAwait(false)) .Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.ExternalProgramFailed); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.ExternalProgramFailed); } // Audit findings are errors under TreatWarningsAsErrors, and the restore wrote every graph all the same. @@ -227,8 +230,8 @@ private static Task> DetectAsync( IDependencyRestorer? restorer = null, bool restoreOverridesAfterwards = false) { - // The restorer is the only thing that would use the solution, and it is faked. - var solution = new Lazy(static () => null!); + // The restorer is the only thing that would use the solution, and it is faked, so an empty one does. + var solution = new Lazy(static () => new SolutionContext("Test.slnx", new SolutionModel())); var detector = new OrphanDetector(solution, restorer ?? new FakeDependencyRestorer(), new CaptureReporter()); return detector.DetectAsync(inventory, restoreOverridesAfterwards); } diff --git a/tests/Buildvana.Tool.Tests/OverrideLifecycleTests.cs b/tests/Buildvana.Tool.Tests/OverrideLifecycleTests.cs index 169d2e25..c874296e 100644 --- a/tests/Buildvana.Tool.Tests/OverrideLifecycleTests.cs +++ b/tests/Buildvana.Tool.Tests/OverrideLifecycleTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using Buildvana.Core; using Buildvana.Core.Configuration; using Buildvana.Core.ConsoleOutput; @@ -9,6 +10,7 @@ using Buildvana.Runtime; using Buildvana.Tool.Services.Dependencies; using Buildvana.Tool.Services.Solution; +using Microsoft.VisualStudio.SolutionPersistence.Model; using NuGet.Versioning; internal sealed class OverrideLifecycleTests @@ -185,8 +187,9 @@ public async Task RunAsync_WithASourceTheRestoreCouldNotRead_ReportsAFailedStep( // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning var exception = await Assert.That(async () => await RunAsync(home, new FakeDependencyRestorer(), Advisories(), Versions()) .ConfigureAwait(false)).Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); - await Assert.That(exception!.ExitCode).IsEqualTo(3); + await Assert.That(exception.ExitCode).IsEqualTo(3); await Assert.That(exception.Message).Contains("could not read a package source"); } @@ -198,8 +201,9 @@ public async Task RunAsync_WithARestoreThatFailedForAnotherReason_ReportsAFailed // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning var exception = await Assert.That(async () => await RunAsync(home, new FakeDependencyRestorer(), Advisories(), Versions()) .ConfigureAwait(false)).Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); - await Assert.That(exception!.ExitCode).IsEqualTo(3); + await Assert.That(exception.ExitCode).IsEqualTo(3); await Assert.That(exception.Message).Contains("NU1101"); } @@ -249,8 +253,9 @@ public async Task RunAsync_WhenTheGraphNeverSettles_ReportsAFailedStep() // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning var exception = await Assert.That(async () => await RunAsync(home, restorer, advisories, versions).ConfigureAwait(false)) .Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); - await Assert.That(exception!.ExitCode).IsEqualTo(1); + await Assert.That(exception.ExitCode).IsEqualTo(1); await Assert.That(exception.Message).Contains("did not settle"); } @@ -260,7 +265,8 @@ private static AssetsFile Finding(string version) private static AssetsFile NeverSettling(int pass) => new AssetsFile().Resolves($"Contoso.Package{pass}", "1.0.0").Reports("NU1902", $"Contoso.Package{pass}"); - private static FakeVulnerabilityDataSource Advisories() => new FakeVulnerabilityDataSource().Knows(Vulnerable, "(, 12.0.2]"); + private static FakeVulnerabilityDataSource Advisories() + => new FakeVulnerabilityDataSource().Knows(Vulnerable, "(, 12.0.2]"); private static FakePackageVersionSource Versions() => new FakePackageVersionSource().Knows(Vulnerable, ["12.0.1", "12.0.2", "12.0.3"]); @@ -302,8 +308,8 @@ private static Task RunAsync( { var actualReporter = reporter ?? new CaptureReporter(); - // The restorer is the only thing that would use the solution, and it is faked. - var solution = new Lazy(static () => null!); + // The restorer is the only thing that would use the solution, and it is faked, so an empty one does. + var solution = new Lazy(static () => new SolutionContext("Test.slnx", new SolutionModel())); var lifecycle = new OverrideLifecycle( solution, home.Provider, diff --git a/tests/Buildvana.Tool.Tests/PackagePinReaderTests.cs b/tests/Buildvana.Tool.Tests/PackagePinReaderTests.cs index f302fff0..e1b9d418 100644 --- a/tests/Buildvana.Tool.Tests/PackagePinReaderTests.cs +++ b/tests/Buildvana.Tool.Tests/PackagePinReaderTests.cs @@ -25,7 +25,7 @@ public async Task Read_StatesOnePinPerEvaluatedItem() """; using var home = new TempHome(); - Write(home, ProjectFileName, project); + home.WriteFile(ProjectFileName, project); var pins = Read( home, Dump( @@ -47,7 +47,7 @@ await Assert.That(pins.Select(static pin => pin.ItemType + " " + pin.Id + " " + public async Task Read_OfOneDeclarationEvaluatedTwice_StatesOnePin() { using var home = new TempHome(); - Write(home, ProjectFileName, OneItemProject("PackageReference", "Serilog", "4.0.0")); + home.WriteFile(ProjectFileName, OneItemProject("PackageReference", "Serilog", "4.0.0")); var item = Item("PackageReference", "Serilog", "4.0.0"); await Assert.That(Read(home, Dump(home, item), Dump(home, item)).Count).IsEqualTo(1); } @@ -67,7 +67,7 @@ public async Task Read_OfTwoVersionsOfOneId_StatesTwoPins() """; using var home = new TempHome(); - Write(home, ProjectFileName, project); + home.WriteFile(ProjectFileName, project); var pins = Read( home, Dump(home, Item("PackageReference", "Serilog", "4.0.0")), @@ -80,7 +80,7 @@ public async Task Read_OfTwoVersionsOfOneId_StatesTwoPins() public async Task Read_LeavesOutWhatIsNotTheRepositorysToMove() { using var home = new TempHome(); - Write(home, ProjectFileName, OneItemProject("PackageReference", "Serilog", "4.0.0")); + home.WriteFile(ProjectFileName, OneItemProject("PackageReference", "Serilog", "4.0.0")); var pins = Read( home, Dump( @@ -110,7 +110,7 @@ public async Task Read_OfAVersionStatedThroughAProperty_IsIndirect() """; using var home = new TempHome(); - Write(home, ProjectFileName, project); + home.WriteFile(ProjectFileName, project); var pins = Read(home, Dump(home, Item("PackageReference", "Serilog", "4.0.0"))); await Assert.That(pins.Single().Management).IsEqualTo(PinManagement.IndirectVersion); } @@ -133,7 +133,7 @@ public async Task Read_OfAVersionStatedAsAChildElement_IsManaged() """; using var home = new TempHome(); - Write(home, ProjectFileName, project); + home.WriteFile(ProjectFileName, project); var pin = Read(home, Dump(home, Item("PackageVersion", "Serilog", "\n 4.0.0\n "))).Single(); await Assert.That(pin.Management).IsEqualTo(PinManagement.Managed); await Assert.That(pin.VersionText).IsEqualTo("4.0.0"); @@ -144,7 +144,7 @@ public async Task Read_OfAVersionStatedAsAChildElement_IsManaged() public async Task Read_OfAPolicyStatedAsAChildElement_StatesThePolicyAlone() { using var home = new TempHome(); - Write(home, ProjectFileName, OneItemProject("PackageVersion", "Serilog", "4.0.0")); + home.WriteFile(ProjectFileName, OneItemProject("PackageVersion", "Serilog", "4.0.0")); var item = Item("PackageVersion", "Serilog", "4.0.0", updatePolicy: "\n patch-\n "); await Assert.That(Read(home, Dump(home, item)).Single().MetadataPolicy).IsEqualTo("patch-"); } @@ -155,7 +155,7 @@ public async Task Read_OfAPolicyStatedAsAChildElement_StatesThePolicyAlone() public async Task Read_OfAnEmptyPolicyElement_StatesNoPolicy() { using var home = new TempHome(); - Write(home, ProjectFileName, OneItemProject("PackageVersion", "Serilog", "4.0.0")); + home.WriteFile(ProjectFileName, OneItemProject("PackageVersion", "Serilog", "4.0.0")); var item = Item("PackageVersion", "Serilog", "4.0.0", updatePolicy: "\n \n "); await Assert.That(Read(home, Dump(home, item)).Single().MetadataPolicy).IsNull(); } @@ -166,7 +166,7 @@ public async Task Read_OfAnEmptyPolicyElement_StatesNoPolicy() public async Task Read_OfAnEmptyVersionElement_IsNoPin() { using var home = new TempHome(); - Write(home, ProjectFileName, OneItemProject("PackageReference", "Serilog", "4.0.0")); + home.WriteFile(ProjectFileName, OneItemProject("PackageReference", "Serilog", "4.0.0")); await Assert.That(Read(home, Dump(home, Item("PackageReference", "Serilog", "\n \n ")))).IsEmpty(); } @@ -176,7 +176,7 @@ public async Task Read_OfAnEmptyVersionElement_IsNoPin() public async Task Read_OfAnEmptyVersionOverrideElement_IsNotAnOverride() { using var home = new TempHome(); - Write(home, ProjectFileName, OneItemProject("PackageReference", "Serilog", "4.0.0")); + home.WriteFile(ProjectFileName, OneItemProject("PackageReference", "Serilog", "4.0.0")); var item = Item("PackageReference", "Serilog", "4.0.0", versionOverride: "\n \n "); var pin = Read(home, Dump(home, item)).Single(); await Assert.That(pin.Management).IsEqualTo(PinManagement.Managed); @@ -197,7 +197,7 @@ public async Task Read_OfAVersionAppliedByAnUpdate_IsIndirect() """; using var home = new TempHome(); - Write(home, ProjectFileName, project); + home.WriteFile(ProjectFileName, project); var pins = Read(home, Dump(home, Item("PackageReference", "Serilog", "4.0.0"))); await Assert.That(pins.Single().Management).IsEqualTo(PinManagement.IndirectVersion); } @@ -214,7 +214,7 @@ public async Task Read_OfAVersionOverride_SaysSo() """; using var home = new TempHome(); - Write(home, ProjectFileName, project); + home.WriteFile(ProjectFileName, project); var item = Item("PackageReference", "Serilog", null, versionOverride: "4.1.0"); var pin = Read(home, Dump(home, item)).Single(); await Assert.That(pin.Management).IsEqualTo(PinManagement.VersionOverride); @@ -225,7 +225,7 @@ public async Task Read_OfAVersionOverride_SaysSo() public async Task Read_OfAnUnmanagedVersionForm_KeepsTheFormsReason() { using var home = new TempHome(); - Write(home, ProjectFileName, OneItemProject("PackageVersion", "Serilog", "[4.0.0]")); + home.WriteFile(ProjectFileName, OneItemProject("PackageVersion", "Serilog", "[4.0.0]")); var pins = Read(home, Dump(home, Item("PackageVersion", "Serilog", "[4.0.0]"))); await Assert.That(pins.Single().Management).IsEqualTo(PinManagement.BracketExactVersion); } @@ -282,11 +282,4 @@ private static string OneItemProject(string itemType, string id, string version) """; - - private static void Write(TempHome home, string relativePath, string content) - { - var path = home.GetFullPath(relativePath); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.WriteAllText(path, content); - } } diff --git a/tests/Buildvana.Tool.Tests/PostUpdateHookArgsFactoryTests.cs b/tests/Buildvana.Tool.Tests/PostUpdateHookArgsFactoryTests.cs index 3b3180db..f7040d80 100644 --- a/tests/Buildvana.Tool.Tests/PostUpdateHookArgsFactoryTests.cs +++ b/tests/Buildvana.Tool.Tests/PostUpdateHookArgsFactoryTests.cs @@ -29,10 +29,11 @@ public async Task Create_StatesWhatTheRunMadeOfEveryScope() var args = Create(home, resolution, check: true); await Assert.That(args.Check).IsTrue(); - await Assert.That(args.NetSdk!.Id).IsNull(); - await Assert.That(args.NetSdk.DeclaringFile).IsEqualTo("global.json"); - await Assert.That(args.NetSdk.Target).IsEqualTo("10.0.201"); - await Assert.That(args.NetSdk.State).IsEqualTo(DependencyResultState.Updated); + var netSdk = await Assert.That(args.NetSdk).IsNotNull(); + await Assert.That(netSdk.Id).IsNull(); + await Assert.That(netSdk.DeclaringFile).IsEqualTo("global.json"); + await Assert.That(netSdk.Target).IsEqualTo("10.0.201"); + await Assert.That(netSdk.State).IsEqualTo(DependencyResultState.Updated); await Assert.That(args.Tools.Single().Id).IsEqualTo("ngbv"); await Assert.That(args.Packages.Single().Id).IsEqualTo("Serilog"); await Assert.That(args.Packages.Single().CurrentVersion).IsEqualTo("3.0.0"); diff --git a/tests/Buildvana.Tool.Tests/ProjectAssetsReaderTests.cs b/tests/Buildvana.Tool.Tests/ProjectAssetsReaderTests.cs index 950e3d4f..8cdaef47 100644 --- a/tests/Buildvana.Tool.Tests/ProjectAssetsReaderTests.cs +++ b/tests/Buildvana.Tool.Tests/ProjectAssetsReaderTests.cs @@ -75,7 +75,9 @@ public async Task Read_StatesThePackagesTheRestoreResolved() { using var home = new TempHome(); var assets = Read(home); - var packages = assets.Packages.Select(static package => package.Id + " " + package.Version.ToNormalizedString()).Order(StringComparer.Ordinal); + var packages = assets.Packages + .Select(static package => package.Id + " " + package.Version.ToNormalizedString()) + .Order(StringComparer.Ordinal); await Assert.That(string.Join(", ", packages)).IsEqualTo("Newtonsoft.Json 12.0.2, Serilog 4.0.0"); } @@ -116,8 +118,8 @@ public async Task Read_WithNoAssetsFile_ReportsAFailedStep() using var home = new TempHome(); var project = home.GetFullPath(ProjectFileName); var assetsPath = home.GetFullPath(AssetsFileName); - var exception = await Assert.That(() => ProjectAssetsReader.Read(project, assetsPath)).Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(3); + var exception = Assert.Throws(() => ProjectAssetsReader.Read(project, assetsPath)); + await Assert.That(exception.ExitCode).IsEqualTo(3); } [Test] @@ -127,8 +129,8 @@ public async Task Read_WithAGraphThatDoesNotParse_ReportsAFailedStep() home.WriteFile(AssetsFileName, "this is not an assets file"); var project = home.GetFullPath(ProjectFileName); var assetsPath = home.GetFullPath(AssetsFileName); - var exception = await Assert.That(() => ProjectAssetsReader.Read(project, assetsPath)).Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(3); + var exception = Assert.Throws(() => ProjectAssetsReader.Read(project, assetsPath)); + await Assert.That(exception.ExitCode).IsEqualTo(3); } private static ProjectAssets Read(TempHome home) diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandChangelogTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandChangelogTests.cs index 2ddc7497..45e63888 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseCommandChangelogTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandChangelogTests.cs @@ -160,8 +160,7 @@ public async Task Release_WithEmptySectionAndNoSubstitute_Fails() // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("release.emptyChangelog"); + await Assert.That(Act).Throws().WithMessageContaining("release.emptyChangelog", StringComparison.Ordinal); // The failure happens before anything is built or committed. await Assert.That(harness.PipelineSteps.Count).IsEqualTo(0); @@ -210,7 +209,8 @@ public async Task Release_PinsFileLinksOfNewSection_ToTheTagActuallyReleased() // the one released, not the one computed before the commit bumped the Git height. The section released // before this one is left as it is. var changelog = harness.ReadFile("CHANGELOG.md"); - const string pinnedUrl = $"https://git.example.invalid/tenacom/test-repo/blob/{ReleasedVersion}/docs/hooks.md#the-build-environment"; + const string pinnedUrl = + $"https://git.example.invalid/tenacom/test-repo/blob/{ReleasedVersion}/docs/hooks.md#the-build-environment"; await Assert.That(changelog).Contains($"[runs from the home directory]({pinnedUrl})"); await Assert.That(changelog).DoesNotContain("blob/2.3.1-preview/"); await Assert.That(changelog).Contains("[the page](docs/released.md)"); @@ -228,8 +228,9 @@ public async Task Release_WithLinkOutsideRepository_FailsNamingTheTarget() // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).IsEqualTo("CHANGELOG.md links '../outside.md', which is not a path to a file in the repository."); + await Assert.That(Act) + .Throws() + .WithMessage("CHANGELOG.md links '../outside.md', which is not a path to a file in the repository.", StringComparison.Ordinal); // The links are pinned once the version is final, after the build, so the failure comes with the // release commit in place. The rollback undoes that commit. diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandFailureTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandFailureTests.cs index ae2f0693..fbf701c8 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseCommandFailureTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandFailureTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using Buildvana.Core; /// @@ -21,8 +22,9 @@ public async Task Release_OnExistingTag_FailsBeforeBuilding() // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains($"Tag '{ReleasedVersion}' already exists"); + await Assert.That(Act) + .Throws() + .WithMessageContaining($"Tag '{ReleasedVersion}' already exists", StringComparison.Ordinal); // The check gates the pipeline run: no step runs, Clean included. await Assert.That(harness.PipelineSteps.Count).IsEqualTo(0); @@ -68,7 +70,7 @@ public async Task Release_WhenPublicationFails_ResetsAndForcePushesTheBranch() // The tag is gone too, locally and on the remote, and the host is asked to undo what it created. await Assert.That(harness.Repo.TagNames.Count).IsEqualTo(0); await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); - var publisher = harness.Publisher!; + var publisher = await Assert.That(harness.Publisher).IsNotNull(); await Assert.That(publisher.IsPublished).IsFalse(); await Assert.That(publisher.IsUndone).IsTrue(); } @@ -81,8 +83,7 @@ public async Task Release_WithoutRepositoryUrl_FailsBeforeBuilding() // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("repository.url"); + await Assert.That(Act).Throws().WithMessageContaining("repository.url", StringComparison.Ordinal); // The preliminary checks run before the pipeline, so a release that can never succeed costs // nothing: no clean, no build, no test run. @@ -98,8 +99,7 @@ public async Task Release_OnAnUnknownHost_WithoutAllowUnknownHost_FailsBeforeBui // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("release.allowUnknownHost"); + await Assert.That(Act).Throws().WithMessageContaining("release.allowUnknownHost", StringComparison.Ordinal); await Assert.That(harness.PipelineSteps.Count).IsEqualTo(0); await Assert.That(harness.Repo.GetRemoteTipSha()).IsNull(); } @@ -116,8 +116,7 @@ public async Task Release_WhenTheReleaseRemoteHasAHigherTag_FailsBeforeBuilding( // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("lower than latest"); + await Assert.That(Act).Throws().WithMessageContaining("lower than latest", StringComparison.Ordinal); await Assert.That(harness.Repo.TagNames).IsEquivalentTo(["2.4.0"]); await Assert.That(harness.PipelineSteps.Count).IsEqualTo(0); } @@ -130,8 +129,7 @@ public async Task Release_WithoutAnyCommitterIdentity_FailsBeforeBuilding() // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("committer identity"); + await Assert.That(Act).Throws().WithMessageContaining("committer identity", StringComparison.Ordinal); await Assert.That(harness.PipelineSteps.Count).IsEqualTo(0); await Assert.That(harness.Events.Count).IsEqualTo(0); } @@ -149,7 +147,8 @@ public async Task Release_WithUncommittedVersionFile_Fails() Task Act() => harness.RunAsync(); var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Git height of 0"); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + await Assert.That(exception.Message).Contains("Git height of 0"); await Assert.That(exception.Message).Contains("Commit VERSION"); // It is caught before anything is packed, and the release commit is rolled back. diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandForgejoTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandForgejoTests.cs index f4d0be76..3cc46f89 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseCommandForgejoTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandForgejoTests.cs @@ -71,8 +71,7 @@ public async Task Release_WithoutPushAccess_OrWithReleasesDisabled_FailsBeforeTh // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains(expectedMessage); + await Assert.That(Act).Throws().WithMessageContaining(expectedMessage, StringComparison.Ordinal); await Assert.That(api.Requests.Count).IsEqualTo(1); await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(remoteTip); await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); @@ -99,9 +98,11 @@ public async Task Release_WithTwoAssetsOfTheSameFileName_FailsBeforeThePush() // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).IsEqualTo( - "The release assets cannot go to a Forgejo release:\n- More than one asset has the file name 'setup.exe'."); + await Assert.That(Act) + .Throws() + .WithMessage( + "The release assets cannot go to a Forgejo release:\n- More than one asset has the file name 'setup.exe'.", + StringComparison.Ordinal); await Assert.That(harness.Events.Select(static x => x.Name)).DoesNotContain("nuget-push"); await Assert.That(api.Requests.Count).IsEqualTo(1); await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(remoteTip); @@ -118,8 +119,7 @@ public async Task Release_WhenAnUploadFails_DeletesTheDraft_ThenTheRecordOfTheTa // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("quota exceeded"); + await Assert.That(Act).Throws().WithMessageContaining("quota exceeded", StringComparison.Ordinal); var deletions = api.Requests.Where(static x => x.Method == HttpMethod.Delete).Select(static x => x.Url); string[] expected = [ReleaseUrl, ApiRepositoryUrl + "/tags/" + ReleasedVersion]; await Assert.That(deletions).IsEquivalentTo(expected, CollectionOrdering.Matching); diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandGitHubTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandGitHubTests.cs index 3f60f2aa..9c328db2 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseCommandGitHubTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandGitHubTests.cs @@ -71,9 +71,11 @@ public async Task Release_WithTwoAssetsOfTheSameFileName_FailsBeforeThePush() // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).IsEqualTo( - "GitHub refuses the release assets:\n- More than one asset has the file name 'setup.exe'."); + await Assert.That(Act) + .Throws() + .WithMessage( + "GitHub refuses the release assets:\n- More than one asset has the file name 'setup.exe'.", + StringComparison.Ordinal); await Assert.That(harness.Events.Select(static x => x.Name)).DoesNotContain("nuget-push"); await Assert.That(api.Requests.Any(static x => x.Url.StartsWith(UploadUrl, StringComparison.Ordinal))).IsFalse(); await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(remoteTip); @@ -90,8 +92,7 @@ public async Task Release_WhenAnUploadFails_DeletesTheDraft_AndTheTag() // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Validation Failed"); + await Assert.That(Act).Throws().WithMessageContaining("Validation Failed", StringComparison.Ordinal); var delete = api.Requests[^1]; await Assert.That(delete.Method).IsEqualTo(HttpMethod.Delete); await Assert.That(delete.Url).IsEqualTo(ApiRepositoryUrl + "/releases/42"); diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandGitLabTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandGitLabTests.cs index 9cec0823..271eb16a 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseCommandGitLabTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandGitLabTests.cs @@ -62,8 +62,7 @@ public async Task Release_BelowTheMaintainerRole_FailsBeforeTheReleaseCommit() // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("below the Maintainer role"); + await Assert.That(Act).Throws().WithMessageContaining("below the Maintainer role", StringComparison.Ordinal); await Assert.That(api.Requests.Count).IsEqualTo(3); await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(remoteTip); await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); @@ -103,8 +102,9 @@ public async Task Release_WithAnAssetGitLabRefuses_FailsBeforeThePush() // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("'Foo 1.zip' is not a valid file name in the generic package registry."); + await Assert.That(Act) + .Throws() + .WithMessageContaining("'Foo 1.zip' is not a valid file name in the generic package registry.", StringComparison.Ordinal); await Assert.That(harness.Events.Select(static x => x.Name)).DoesNotContain("nuget-push"); await Assert.That(api.Requests.Any(static x => x.Method == HttpMethod.Put)).IsFalse(); await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(remoteTip); @@ -121,8 +121,7 @@ public async Task Release_WhenTheReleaseCannotBeCreated_DeletesThePackage_AndThe // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("Validation failed"); + await Assert.That(Act).Throws().WithMessageContaining("Validation failed", StringComparison.Ordinal); var delete = api.Requests[^1]; await Assert.That(delete.Method).IsEqualTo(HttpMethod.Delete); await Assert.That(delete.Url).IsEqualTo(ApiProjectUrl + "/packages/7"); diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandGiteaTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandGiteaTests.cs index 0dcb24b0..25ca40a6 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseCommandGiteaTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandGiteaTests.cs @@ -71,8 +71,7 @@ public async Task Release_WithoutPushAccess_OrWithReleasesDisabled_FailsBeforeTh // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains(expectedMessage); + await Assert.That(Act).Throws().WithMessageContaining(expectedMessage, StringComparison.Ordinal); await Assert.That(api.Requests.Count).IsEqualTo(1); await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(remoteTip); await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsNull(); @@ -99,9 +98,11 @@ public async Task Release_WithTwoAssetsOfTheSameFileName_FailsBeforeThePush() // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).IsEqualTo( - "The release assets cannot go to a Gitea release:\n- More than one asset has the file name 'setup.exe'."); + await Assert.That(Act) + .Throws() + .WithMessage( + "The release assets cannot go to a Gitea release:\n- More than one asset has the file name 'setup.exe'.", + StringComparison.Ordinal); await Assert.That(harness.Events.Select(static x => x.Name)).DoesNotContain("nuget-push"); await Assert.That(api.Requests.Count).IsEqualTo(1); await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(remoteTip); @@ -118,8 +119,7 @@ public async Task Release_WhenAnUploadFails_DeletesTheDraft_ThenTheRecordOfTheTa // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes Act before the harness is disposed Task Act() => harness.RunAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("quota exceeded"); + await Assert.That(Act).Throws().WithMessageContaining("quota exceeded", StringComparison.Ordinal); var deletions = api.Requests.Where(static x => x.Method == HttpMethod.Delete).Select(static x => x.Url); string[] expected = [ReleaseUrl, ApiRepositoryUrl + "/tags/" + ReleasedVersion]; await Assert.That(deletions).IsEquivalentTo(expected, CollectionOrdering.Matching); diff --git a/tests/Buildvana.Tool.Tests/ReleaseCommandTests.cs b/tests/Buildvana.Tool.Tests/ReleaseCommandTests.cs index f4678cd2..43a1436c 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseCommandTests.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseCommandTests.cs @@ -81,7 +81,7 @@ public async Task Release_TagsTheReleaseCommit_PushesTheTag_AndPublishesOnIt() await Assert.That(releaseCommit.Message).IsEqualTo(ReleaseCommitMessage); await Assert.That(harness.Repo.TagNames).IsEquivalentTo([ReleasedVersion]); await Assert.That(harness.Repo.GetRemoteTagSha(ReleasedVersion)).IsEqualTo(releaseCommit.Sha); - var publisher = harness.Publisher!; + var publisher = await Assert.That(harness.Publisher).IsNotNull(); await Assert.That(publisher.PublishedTag).IsEqualTo(ReleasedVersion); await Assert.That(publisher.PublishedSha).IsEqualTo(releaseCommit.Sha); } @@ -201,7 +201,7 @@ public async Task Release_PublishesEveryProducedPackage_AndItsSymbols() _ = await harness.RunAsync().ConfigureAwait(false); - var publisher = harness.Publisher!; + var publisher = await Assert.That(harness.Publisher).IsNotNull(); await Assert.That(publisher.IsPublished).IsTrue(); var assets = publisher.PublishedAssets; var names = assets.Select(x => Path.GetFileName(x.Path)).ToArray(); diff --git a/tests/Buildvana.Tool.Tests/ReleaseHarness.cs b/tests/Buildvana.Tool.Tests/ReleaseHarness.cs index 84d6d45a..8a12f9d6 100644 --- a/tests/Buildvana.Tool.Tests/ReleaseHarness.cs +++ b/tests/Buildvana.Tool.Tests/ReleaseHarness.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; using Buildvana.Core.ConsoleOutput; @@ -234,7 +235,9 @@ public string ComputeVersion() public void WriteFile(string relativePath, string content) { var path = Path.Combine(Repo.RootPath, relativePath); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var directory = Path.GetDirectoryName(path); + Debug.Assert(directory is not null, "GetDirectoryName returns null only for a root, and a file path is not one."); + _ = Directory.CreateDirectory(directory); File.WriteAllText(path, content); } diff --git a/tests/Buildvana.Tool.Tests/SelfReferenceUpdaterTests.cs b/tests/Buildvana.Tool.Tests/SelfReferenceUpdaterTests.cs index 2b12a881..8ef976de 100644 --- a/tests/Buildvana.Tool.Tests/SelfReferenceUpdaterTests.cs +++ b/tests/Buildvana.Tool.Tests/SelfReferenceUpdaterTests.cs @@ -31,13 +31,13 @@ public async Task UpdateReferences_WithManifestUnderDotConfig_Fails() home.WriteFile(".config/dotnet-tools.json", Manifest); var updater = CreateUpdater(home); - var exception = await Assert + await Assert .That(() => updater.UpdateReferences(new Dictionary { ["bv"] = "2.1.41-preview" })) - .Throws(); - - await Assert.That(exception!.Message).Contains("git mv .config/dotnet-tools.json dotnet-tools.json"); + .Throws() + .WithMessageContaining("git mv .config/dotnet-tools.json dotnet-tools.json", StringComparison.Ordinal); await Assert.That(home.ReadFile(".config/dotnet-tools.json")).IsEqualTo(Manifest); } - private static SelfReferenceUpdater CreateUpdater(TempHome home) => new(NullReporter.Instance, home.Provider, new JsonHelper()); + private static SelfReferenceUpdater CreateUpdater(TempHome home) + => new(NullReporter.Instance, home.Provider, new JsonHelper()); } diff --git a/tests/Buildvana.Tool.Tests/SelfUpdateCommandTests.cs b/tests/Buildvana.Tool.Tests/SelfUpdateCommandTests.cs index 654ecddd..50c708fe 100644 --- a/tests/Buildvana.Tool.Tests/SelfUpdateCommandTests.cs +++ b/tests/Buildvana.Tool.Tests/SelfUpdateCommandTests.cs @@ -142,11 +142,10 @@ public async Task ExecuteAsync_WithInvalidToVersion_FailsBeforeChangingAnything( var settings = new SelfUpdateSettings { To = "not-a-version" }; var command = new SelfUpdateCommand(CreateService(home, runner), settings, new TestConsole()); - var exception = await Assert + await Assert .That(async () => _ = await command.ExecuteAsync(CancellationToken.None).ConfigureAwait(false)) - .Throws(); - - await Assert.That(exception!.Message).Contains("--to"); + .Throws() + .WithMessageContaining("--to", StringComparison.Ordinal); await Assert.That(runner.Runs.Count).IsEqualTo(0); } diff --git a/tests/Buildvana.Tool.Tests/SelfUpdateSettingsTests.cs b/tests/Buildvana.Tool.Tests/SelfUpdateSettingsTests.cs index c9a47ad1..f6518166 100644 --- a/tests/Buildvana.Tool.Tests/SelfUpdateSettingsTests.cs +++ b/tests/Buildvana.Tool.Tests/SelfUpdateSettingsTests.cs @@ -48,9 +48,9 @@ public async Task ResolveTo_WithInvalidVersion_Fails() { var settings = SelfUpdateSettings.Parse(["--to", "not-a-version"]); - var exception = await Assert.That(() => _ = settings.ResolveTo()).Throws(); + var exception = Assert.Throws(() => _ = settings.ResolveTo()); - await Assert.That(exception!.Message).Contains("--to"); + await Assert.That(exception.Message).Contains("--to"); await Assert.That(exception.Message).Contains("not-a-version"); } @@ -99,9 +99,9 @@ public async Task Parse_WithForce_AndATargetOption_Succeeds(string option) [Arguments("--preview", "--repair", "--to=2.1.40-preview")] public async Task Parse_WithTwoTargetOptions_Fails(params string[] options) { - var exception = await Assert.That(() => _ = SelfUpdateSettings.Parse(options)).Throws(); + var exception = Assert.Throws(() => _ = SelfUpdateSettings.Parse(options)); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.Usage); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.Usage); foreach (var option in options) { await Assert.That(exception.Message).Contains(option.Split('=')[0]); diff --git a/tests/Buildvana.Tool.Tests/SelfUpdateTargetResolverTests.cs b/tests/Buildvana.Tool.Tests/SelfUpdateTargetResolverTests.cs index bfe3d510..73e574e6 100644 --- a/tests/Buildvana.Tool.Tests/SelfUpdateTargetResolverTests.cs +++ b/tests/Buildvana.Tool.Tests/SelfUpdateTargetResolverTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using Buildvana.Core; using Buildvana.Tool.Services; using Buildvana.Tool.Services.SelfUpdate; @@ -48,8 +49,9 @@ public async Task Resolve_WithRepair_AndNoManifestEntry_Fails() var exception = await Assert .That(async () => _ = await Resolve(source, request, NoEntry).ConfigureAwait(false)) .Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); - await Assert.That(exception!.Message).Contains("--repair"); + await Assert.That(exception.Message).Contains("--repair"); await Assert.That(exception.Message).Contains("dotnet-tools.json"); await Assert.That(source.Asked.Count).IsEqualTo(0); } @@ -186,11 +188,10 @@ public async Task Resolve_WithoutSources_Fails() { var source = new FakePackageVersionSource { Sources = [] }; - var exception = await Assert + await Assert .That(async () => _ = await Resolve(source, SelfUpdateRequest.Default, Pin("2.1.8")).ConfigureAwait(false)) - .Throws(); - - await Assert.That(exception!.Message).Contains("No package source is configured"); + .Throws() + .WithMessageContaining("No package source is configured", StringComparison.Ordinal); } // A source that knows nothing of bv is a misconfigured source, and the message names the sources so @@ -203,13 +204,17 @@ public async Task Resolve_WhenNoSourceKnowsBv_Fails() var exception = await Assert .That(async () => _ = await Resolve(source, SelfUpdateRequest.Default, Pin("2.1.8")).ConfigureAwait(false)) .Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); - await Assert.That(exception!.Message).Contains("No configured package source knows bv"); + await Assert.That(exception.Message).Contains("No configured package source knows bv"); await Assert.That(exception.Message).Contains("nuget.org, private"); } private static BvManifestPin Pin(string version) => new(true, version, NuGetVersion.Parse(version)); - private static Task Resolve(FakePackageVersionSource source, SelfUpdateRequest request, BvManifestPin pin) + private static Task Resolve( + FakePackageVersionSource source, + SelfUpdateRequest request, + BvManifestPin pin) => new SelfUpdateTargetResolver(source, NuGetVersion.Parse(OwnVersion)).ResolveAsync(request, pin); } diff --git a/tests/Buildvana.Tool.Tests/SelfVersionServiceTests.cs b/tests/Buildvana.Tool.Tests/SelfVersionServiceTests.cs index 78aaa8be..086b4288 100644 --- a/tests/Buildvana.Tool.Tests/SelfVersionServiceTests.cs +++ b/tests/Buildvana.Tool.Tests/SelfVersionServiceTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using Buildvana.Core; using Buildvana.Core.Configuration; using Buildvana.Core.ConsoleOutput; @@ -37,9 +38,9 @@ public async Task EnsureSdkVersionMatch_WithMismatchedPin_FailsNamingBothVersion WriteGlobalJson(home, pin); var service = CreateService(home, "2.1.41-preview"); - var exception = await Assert.That(service.EnsureSdkVersionMatch).Throws(); + var exception = Assert.Throws(service.EnsureSdkVersionMatch); - await Assert.That(exception!.Message).Contains(pin); + await Assert.That(exception.Message).Contains(pin); await Assert.That(exception.Message).Contains("2.1.41-preview"); await Assert.That(exception.Message).Contains("dotnet bv self-update --repair"); } @@ -50,9 +51,9 @@ public async Task EnsureSdkVersionMatch_WithoutGlobalJson_Fails() using var home = new TempHome(); var service = CreateService(home, "2.1.41-preview"); - var exception = await Assert.That(service.EnsureSdkVersionMatch).Throws(); + var exception = Assert.Throws(service.EnsureSdkVersionMatch); - await Assert.That(exception!.Message).Contains("global.json"); + await Assert.That(exception.Message).Contains("global.json"); await Assert.That(exception.Message).Contains("dotnet bv self-update --repair"); } @@ -63,9 +64,9 @@ public async Task EnsureSdkVersionMatch_WithoutSdksSection_Fails() home.WriteFile("global.json", """{ "sdk": { "version": "10.0.302" } }"""); var service = CreateService(home, "2.1.41-preview"); - var exception = await Assert.That(service.EnsureSdkVersionMatch).Throws(); - - await Assert.That(exception!.Message).Contains("msbuild-sdks"); + await Assert.That(service.EnsureSdkVersionMatch) + .Throws() + .WithMessageContaining("msbuild-sdks", StringComparison.Ordinal); } [Test] @@ -75,9 +76,9 @@ public async Task EnsureSdkVersionMatch_WithoutPinEntry_Fails() home.WriteFile("global.json", """{ "msbuild-sdks": { "Microsoft.Build.NoTargets": "3.7.134" } }"""); var service = CreateService(home, "2.1.41-preview"); - var exception = await Assert.That(service.EnsureSdkVersionMatch).Throws(); - - await Assert.That(exception!.Message).Contains("Buildvana.Sdk"); + await Assert.That(service.EnsureSdkVersionMatch) + .Throws() + .WithMessageContaining("Buildvana.Sdk", StringComparison.Ordinal); } [Test] @@ -87,9 +88,9 @@ public async Task EnsureSdkVersionMatch_WithUnparseablePin_Fails() WriteGlobalJson(home, "not-a-version"); var service = CreateService(home, "2.1.41-preview"); - var exception = await Assert.That(service.EnsureSdkVersionMatch).Throws(); - - await Assert.That(exception!.Message).Contains("not-a-version"); + await Assert.That(service.EnsureSdkVersionMatch) + .Throws() + .WithMessageContaining("not-a-version", StringComparison.Ordinal); } // No file changes, but the dotnet CLI still runs: a matching manifest pin proves neither that the @@ -98,7 +99,9 @@ public async Task EnsureSdkVersionMatch_WithUnparseablePin_Fails() [Arguments("2.1.41-preview", "2.1.41-preview")] [Arguments("2.1.41-preview+g0123abc", "2.1.41-preview")] [Arguments("2.1.41-preview", "2.1.41-preview+g0123abc")] - public async Task UpdateRepository_WhenEverythingCurrent_RunsToolUpdateButChangesNoFile(string ownVersion, string pin) + public async Task UpdateRepository_WhenEverythingCurrent_RunsToolUpdateButChangesNoFile( + string ownVersion, + string pin) { using var home = new TempHome(); WriteGlobalJson(home, pin); @@ -203,11 +206,10 @@ public async Task UpdateRepository_WhenTemplateCreatesNoManifest_FailsBeforeChan var runner = new FakeProcessRunner(); var service = CreateService(home, "2.1.41-preview", runner); - var exception = await Assert + await Assert .That(async () => _ = await UpdateInPlaceAsync(service, SelfUpdateRequest.Default).ConfigureAwait(false)) - .Throws(); - - await Assert.That(exception!.Message).Contains("'dotnet new tool-manifest' did not create dotnet-tools.json"); + .Throws() + .WithMessageContaining("'dotnet new tool-manifest' did not create dotnet-tools.json", StringComparison.Ordinal); await Assert.That(runner.Runs.Count).IsEqualTo(1); await Assert.That(home.ReadFile("global.json")).IsEqualTo(before); } @@ -341,7 +343,8 @@ public async Task UpdateRepository_WithoutGlobalJson_CreatesIt() _ = await UpdateInPlaceAsync(service, SelfUpdateRequest.Default).ConfigureAwait(false); - await Assert.That(home.ReadFile("global.json")).IsEqualTo("{\n \"msbuild-sdks\": {\n \"Buildvana.Sdk\": \"2.1.41-preview\"\n }\n}\n"); + await Assert.That(home.ReadFile("global.json")) + .IsEqualTo("{\n \"msbuild-sdks\": {\n \"Buildvana.Sdk\": \"2.1.41-preview\"\n }\n}\n"); } // A directory named global.json is invisible to File.Exists, so the update takes the create-new-file path @@ -357,8 +360,9 @@ public async Task UpdateRepository_WithDeniedGlobalJsonWrite_Fails() var exception = await Assert .That(async () => _ = await UpdateInPlaceAsync(service, SelfUpdateRequest.Default).ConfigureAwait(false)) .Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); - await Assert.That(exception!.Message).Contains("Could not write to"); + await Assert.That(exception.Message).Contains("Could not write to"); await Assert.That(exception.InnerException).IsTypeOf(); } @@ -366,7 +370,10 @@ public async Task UpdateRepository_WithDeniedGlobalJsonWrite_Fails() [Arguments("2.1.42-preview", "2.1.41-preview", "tool manifest")] [Arguments("2.1.41-preview", "2.1.42-preview", "global.json")] [Arguments("2.1.42-preview", "2.1.42-preview", "tool manifest")] - public async Task UpdateRepository_WithNewerPin_FailsWithoutForce(string manifestPin, string sdkPin, string offender) + public async Task UpdateRepository_WithNewerPin_FailsWithoutForce( + string manifestPin, + string sdkPin, + string offender) { using var home = new TempHome(); WriteGlobalJson(home, sdkPin); @@ -378,8 +385,9 @@ public async Task UpdateRepository_WithNewerPin_FailsWithoutForce(string manifes var exception = await Assert .That(async () => _ = await UpdateInPlaceAsync(service, To("2.1.41-preview")).ConfigureAwait(false)) .Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); - await Assert.That(exception!.Message).Contains("downgrade"); + await Assert.That(exception.Message).Contains("downgrade"); await Assert.That(exception.Message).Contains("dotnet bv self-update --to "); await Assert.That(exception.Message).Contains("--force"); await Assert.That(exception.Message).Contains(offender); @@ -401,8 +409,9 @@ public async Task UpdateRepository_ByDefault_WithNewerSdkPin_FailsWithoutForce() var exception = await Assert .That(async () => _ = await UpdateInPlaceAsync(service, SelfUpdateRequest.Default).ConfigureAwait(false)) .Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); - await Assert.That(exception!.Message).StartsWith("The latest bv on the package sources is 2.1.41-preview, but "); + await Assert.That(exception.Message).StartsWith("The latest bv on the package sources is 2.1.41-preview, but "); await Assert.That(exception.Message).Contains("global.json pins Buildvana.Sdk 2.1.42-preview"); await Assert.That(runner.Runs.Count).IsEqualTo(0); } @@ -425,13 +434,13 @@ public async Task UpdateRepository_WithThreeNewerPins_ListsThemReadably() home.WriteFile("Directory.Packages.props", project); var service = CreateService(home, "2.1.41-preview"); - var exception = await Assert + await Assert .That(async () => _ = await UpdateInPlaceAsync(service, To("2.1.41-preview")).ConfigureAwait(false)) - .Throws(); - - await Assert.That(exception!.Message).Contains( - "the tool manifest pins bv 2.1.42-preview, global.json pins Buildvana.Sdk 2.1.42-preview, " - + "and Directory.Packages.props pins Buildvana.Runtime 2.1.42-preview"); + .Throws() + .WithMessageContaining( + "the tool manifest pins bv 2.1.42-preview, global.json pins Buildvana.Sdk 2.1.42-preview, " + + "and Directory.Packages.props pins Buildvana.Runtime 2.1.42-preview", + StringComparison.Ordinal); } [Test] @@ -510,8 +519,9 @@ public async Task UpdateRepository_WithOlderToVersion_FailsWithoutForce_NamingTh var exception = await Assert .That(async () => _ = await UpdateInPlaceAsync(service, To("2.1.40-preview")).ConfigureAwait(false)) .Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); - await Assert.That(exception!.Message).Contains("--to"); + await Assert.That(exception.Message).Contains("--to"); await Assert.That(exception.Message).Contains("2.1.40-preview"); await Assert.That(exception.Message).Contains("downgrade"); await Assert.That(runner.Runs.Count).IsEqualTo(0); @@ -562,7 +572,7 @@ await Assert.That(handoff.Args).IsEquivalentTo( ["tool", "run", "bv", "--", "--nologo", "self-update", "--to", "2.1.45-preview"], CollectionOrdering.Matching); await Assert.That(handoff.Environment).IsNotNull(); - await Assert.That(handoff.Environment!["BV_SELF_UPDATE_FROM"]).IsEqualTo("2.1.40-preview"); + await Assert.That(handoff.Environment?["BV_SELF_UPDATE_FROM"]).IsEqualTo("2.1.40-preview"); await Assert.That(handoff.WorkingDirectory).IsEqualTo(home.RootPath); await Assert.That(home.ReadFile("global.json")).IsEqualTo(GlobalJsonText("2.1.40-preview")); await Assert.That(result.Summary).IsNull(); @@ -621,7 +631,7 @@ public async Task UpdateRepository_WhenHandingOff_WithoutManifestEntry_PassesAnE await Assert.That(runner.Runs[0].Args).IsEquivalentTo( ["tool", "install", "bv", "--version", "2.1.45-preview", "--tool-manifest", "dotnet-tools.json"], CollectionOrdering.Matching); - await Assert.That(runner.InheritedStdioRuns[0].Environment!["BV_SELF_UPDATE_FROM"]).IsEqualTo(string.Empty); + await Assert.That(runner.InheritedStdioRuns[0].Environment?["BV_SELF_UPDATE_FROM"]).IsEqualTo(string.Empty); } [Test] @@ -648,7 +658,9 @@ public async Task UpdateRepository_WhenHandingOff_ReportsTheVersionThatTakesOver [Arguments("", "bv: 2.1.41-preview (tool manifest, added)")] [Arguments("2.1.41-preview", "bv: 2.1.41-preview (tool manifest, unchanged)")] [Arguments("not-a-version", "bv: 2.1.41-preview (tool manifest, unchanged)")] - public async Task UpdateRepository_WhenHandedOff_ReportsTheManifestMoveOfTheHandingOffRun(string from, string expectedLine) + public async Task UpdateRepository_WhenHandedOff_ReportsTheManifestMoveOfTheHandingOffRun( + string from, + string expectedLine) { using var home = new TempHome(); WriteGlobalJson(home, "2.1.40-preview"); @@ -695,11 +707,10 @@ public async Task UpdateRepository_WithRepair_AndNoManifestEntry_FailsBeforeChan var runner = new FakeProcessRunner(); var service = CreateService(home, "2.1.41-preview", runner); - var exception = await Assert + await Assert .That(async () => _ = await UpdateInPlaceAsync(service, new SelfUpdateRequest { Repair = true }).ConfigureAwait(false)) - .Throws(); - - await Assert.That(exception!.Message).Contains("--repair"); + .Throws() + .WithMessageContaining("--repair", StringComparison.Ordinal); await Assert.That(runner.Runs.Count).IsEqualTo(0); await Assert.That(home.ReadFile("global.json")).IsEqualTo(before); } @@ -733,8 +744,10 @@ public async Task UpdateRepository_StampsFamilyPins() var summary = await UpdateInPlaceAsync(service, SelfUpdateRequest.Default).ConfigureAwait(false); - await Assert.That(home.ReadFile("App.csproj")).IsEqualTo(project.Replace("2.1.40-preview", "2.1.41-preview", StringComparison.Ordinal)); - await Assert.That(home.ReadFile(hookPath)).IsEqualTo(hook.Replace("2.1.40-preview", "2.1.41-preview", StringComparison.Ordinal)); + await Assert.That(home.ReadFile("App.csproj")) + .IsEqualTo(project.Replace("2.1.40-preview", "2.1.41-preview", StringComparison.Ordinal)); + await Assert.That(home.ReadFile(hookPath)) + .IsEqualTo(hook.Replace("2.1.40-preview", "2.1.41-preview", StringComparison.Ordinal)); await Assert.That(summary.FamilyPinLines).IsEquivalentTo([ "Buildvana.Runtime: 2.1.40-preview -> 2.1.41-preview (.buildvana/hooks/hook.cs)", "Buildvana.Runtime: 2.1.40-preview -> 2.1.41-preview (App.csproj)", @@ -762,8 +775,9 @@ public async Task UpdateRepository_WithNewerFamilyPin_FailsWithoutForce() var exception = await Assert .That(async () => _ = await UpdateInPlaceAsync(service, SelfUpdateRequest.Default).ConfigureAwait(false)) .Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); - await Assert.That(exception!.Message).Contains("Directory.Packages.props pins Buildvana.Runtime 2.1.42-preview"); + await Assert.That(exception.Message).Contains("Directory.Packages.props pins Buildvana.Runtime 2.1.42-preview"); await Assert.That(exception.Message).Contains("downgrade"); await Assert.That(runner.Runs.Count).IsEqualTo(0); await Assert.That(home.ReadFile("Directory.Packages.props")).IsEqualTo(project); @@ -772,9 +786,15 @@ public async Task UpdateRepository_WithNewerFamilyPin_FailsWithoutForce() // An entry whose version the dotnet CLI cannot parse is beyond any `dotnet tool` verb's reach — update must // fail up front with a message pointing at the file, before touching anything. [Test] - [Arguments("""{ "version": 1, "isRoot": true, "tools": { "bv": { "version": "not-a-version", "commands": [ "bv" ] } } }""", "not-a-version")] - [Arguments("""{ "version": 1, "isRoot": true, "tools": { "bv": { "commands": [ "bv" ] } } }""", "has no version")] - public async Task UpdateRepository_WithUnusableManifestEntry_FailsBeforeChangingAnything(string manifestContent, string expectedDetail) + [Arguments( + """{ "version": 1, "isRoot": true, "tools": { "bv": { "version": "not-a-version", "commands": [ "bv" ] } } }""", + "not-a-version")] + [Arguments( + """{ "version": 1, "isRoot": true, "tools": { "bv": { "commands": [ "bv" ] } } }""", + "has no version")] + public async Task UpdateRepository_WithUnusableManifestEntry_FailsBeforeChangingAnything( + string manifestContent, + string expectedDetail) { using var home = new TempHome(); WriteGlobalJson(home, "2.1.40-preview"); @@ -786,8 +806,9 @@ public async Task UpdateRepository_WithUnusableManifestEntry_FailsBeforeChanging var exception = await Assert .That(async () => _ = await UpdateInPlaceAsync(service, SelfUpdateRequest.Default).ConfigureAwait(false)) .Throws(); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); - await Assert.That(exception!.Message).Contains(expectedDetail); + await Assert.That(exception.Message).Contains(expectedDetail); await Assert.That(exception.Message).Contains("dotnet-tools.json"); await Assert.That(runner.Runs.Count).IsEqualTo(0); await Assert.That(home.ReadFile("global.json")).IsEqualTo(before); @@ -805,11 +826,10 @@ public async Task UpdateRepository_WithManifestUnderDotConfig_FailsBeforeChangin var runner = new FakeProcessRunner(); var service = CreateService(home, "2.1.41-preview", runner); - var exception = await Assert + await Assert .That(async () => _ = await UpdateInPlaceAsync(service, SelfUpdateRequest.Default).ConfigureAwait(false)) - .Throws(); - - await Assert.That(exception!.Message).Contains("git mv .config/dotnet-tools.json dotnet-tools.json"); + .Throws() + .WithMessageContaining("git mv .config/dotnet-tools.json dotnet-tools.json", StringComparison.Ordinal); await Assert.That(runner.Runs.Count).IsEqualTo(0); await Assert.That(home.ReadFile("global.json")).IsEqualTo(before); } @@ -821,7 +841,12 @@ public async Task UpdateRepository_WhenToolUpdateFails_Propagates() WriteGlobalJson(home, "2.1.41-preview"); WriteToolManifest(home, "2.1.40-preview"); var runner = new FakeProcessRunner { - OnRun = static (executable, args) => new ProcessResult($"{executable} {string.Join(' ', args)}", 1, string.Empty, "simulated failure", TimeSpan.Zero), + OnRun = static (executable, args) => new ProcessResult( + $"{executable} {string.Join(' ', args)}", + 1, + string.Empty, + "simulated failure", + TimeSpan.Zero), }; var service = CreateService(home, "2.1.41-preview", runner); @@ -868,7 +893,8 @@ public async Task UpdateRepository_WithSchemaReference_PreservesCommentsAndForma _ = await UpdateInPlaceAsync(service, SelfUpdateRequest.Default).ConfigureAwait(false); - await Assert.That(home.ReadFile("buildvana.jsonc")).IsEqualTo(content.Replace("2.1.40-preview", "2.1.41-preview", StringComparison.Ordinal)); + await Assert.That(home.ReadFile("buildvana.jsonc")) + .IsEqualTo(content.Replace("2.1.40-preview", "2.1.41-preview", StringComparison.Ordinal)); } [Test] @@ -965,11 +991,10 @@ public async Task UpdateRepository_WithMultipleConfigFiles_Fails() home.WriteFile("buildvana.jsonc", "{}\n"); var service = CreateService(home, "2.1.41-preview"); - var exception = await Assert + await Assert .That(async () => _ = await UpdateInPlaceAsync(service, SelfUpdateRequest.Default).ConfigureAwait(false)) - .Throws(); - - await Assert.That(exception!.Message).Contains("Multiple"); + .Throws() + .WithMessageContaining("Multiple", StringComparison.Ordinal); } // The sources list the running bv's own version and nothing else, unless a test scripts them: the default @@ -1007,11 +1032,12 @@ private static SelfUpdateRequest To(string version, bool force = false) => new() { To = NuGetVersion.Parse(version), Force = force }; // Most tests exercise an update this bv performs itself, and read its summary. - private static async Task UpdateInPlaceAsync(SelfVersionService service, SelfUpdateRequest request) + private static async Task UpdateInPlaceAsync( + SelfVersionService service, + SelfUpdateRequest request) { var result = await service.UpdateRepositoryAsync(request).ConfigureAwait(false); - await Assert.That(result.Summary).IsNotNull(); - return result.Summary!; + return await Assert.That(result.Summary).IsNotNull(); } private static List WarningsOf(CaptureReporter reporter) => MessagesOf(reporter, MessageLevel.Warning); @@ -1019,7 +1045,8 @@ private static async Task UpdateInPlaceAsync(SelfVersionServi private static List MessagesOf(CaptureReporter reporter, MessageLevel level) => [.. reporter.Messages.Where(m => m.Level == level).Select(static m => m.Message)]; - private static void WriteGlobalJson(TempHome home, string pin) => home.WriteFile("global.json", GlobalJsonText(pin)); + private static void WriteGlobalJson(TempHome home, string pin) + => home.WriteFile("global.json", GlobalJsonText(pin)); private static string SchemaConfigText(string version) => $$""" { @@ -1061,5 +1088,6 @@ private static void WriteToolManifest(TempHome home, string version) WriteRawToolManifest(home, content); } - private static void WriteRawToolManifest(TempHome home, string content) => home.WriteFile("dotnet-tools.json", content); + private static void WriteRawToolManifest(TempHome home, string content) + => home.WriteFile("dotnet-tools.json", content); } diff --git a/tests/Buildvana.Tool.Tests/ServerReleaseTests.cs b/tests/Buildvana.Tool.Tests/ServerReleaseTests.cs index 92e518e9..452938aa 100644 --- a/tests/Buildvana.Tool.Tests/ServerReleaseTests.cs +++ b/tests/Buildvana.Tool.Tests/ServerReleaseTests.cs @@ -32,8 +32,9 @@ public async Task EnsureReleaseCommit_AfterPushingUpdates_Throws() void Act() => release.EnsureReleaseCommit(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("already been pushed"); + await Assert.That(Act) + .Throws() + .WithMessageContaining("already been pushed", StringComparison.Ordinal); } } @@ -50,8 +51,9 @@ public async Task UpdateRepository_AfterPushingUpdates_Throws() void Act() => release.UpdateRepository("docs/late.md"); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("already been pushed"); + await Assert.That(Act) + .Throws() + .WithMessageContaining("already been pushed", StringComparison.Ordinal); } } @@ -68,8 +70,9 @@ public async Task UpdateRepository_AfterAPostReleaseCommit_Throws() void Act() => release.UpdateRepository("docs/late.md"); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("after a post-release commit"); + await Assert.That(Act) + .Throws() + .WithMessageContaining("after a post-release commit", StringComparison.Ordinal); } } @@ -86,8 +89,9 @@ public async Task AddPostReleaseCommit_AfterPushingUpdates_Throws() void Act() => release.AddPostReleaseCommit("Post-release updates [skip ci]", "docs/late.md"); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("already been pushed"); + await Assert.That(Act) + .Throws() + .WithMessageContaining("already been pushed", StringComparison.Ordinal); } } @@ -102,8 +106,9 @@ public async Task AddPostReleaseCommit_BeforeTheReleaseCommit_Throws() void Act() => release.AddPostReleaseCommit("Post-release updates [skip ci]", "docs/early.md"); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("before the release commit"); + await Assert.That(Act) + .Throws() + .WithMessageContaining("before the release commit", StringComparison.Ordinal); } } @@ -117,8 +122,9 @@ public async Task PushUpdates_WithoutAReleaseCommit_Throws() { void Act() => release.PushUpdates(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("before the release commit"); + await Assert.That(Act) + .Throws() + .WithMessageContaining("before the release commit", StringComparison.Ordinal); } await Assert.That(harness.Repo.GetRemoteTipSha()).IsNull(); @@ -158,8 +164,9 @@ public async Task AddAsset_AfterPushingUpdates_Throws() void Act() => release.AddAsset("artifacts/late.bin"); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("already been pushed"); + await Assert.That(Act) + .Throws() + .WithMessageContaining("already been pushed", StringComparison.Ordinal); } } @@ -176,7 +183,8 @@ public async Task PushUpdates_HasThePublisherCheckTheAssets() release.PushUpdates(); ReleaseAsset[] expected = [new("artifacts/setup.exe", "Setup program", "application/vnd.microsoft.portable-executable")]; - await Assert.That(harness.Publisher!.CheckedAssets).IsEquivalentTo(expected); + var publisher = await Assert.That(harness.Publisher).IsNotNull(); + await Assert.That(publisher.CheckedAssets).IsEquivalentTo(expected); } } @@ -191,9 +199,11 @@ public async Task PublishAsync_BeforePushingUpdates_Throws() Task Act() => release.PublishAsync(); - var exception = await Assert.That(Act).Throws(); - await Assert.That(exception!.Message).Contains("before pushing updates"); - await Assert.That(harness.Publisher!.IsPublished).IsFalse(); + await Assert.That(Act) + .Throws() + .WithMessageContaining("before pushing updates", StringComparison.Ordinal); + var publisher = await Assert.That(harness.Publisher).IsNotNull(); + await Assert.That(publisher.IsPublished).IsFalse(); } } @@ -208,14 +218,14 @@ public async Task PublishAsync_PublishesOnTheTag_AtTheReleaseCommit() release.PushUpdates(); await release.PublishAsync().ConfigureAwait(false); - var publisher = harness.Publisher!; + var publisher = await Assert.That(harness.Publisher).IsNotNull(); await Assert.That(publisher.IsBegun).IsTrue(); await Assert.That(publisher.PublishedTag).IsEqualTo(harness.ComputeVersion()); await Assert.That(publisher.PublishedSha).IsEqualTo(release.ReleaseCommitSha); } // A published release is final: disposing it undoes nothing. - await Assert.That(harness.Publisher!.IsUndone).IsFalse(); + await Assert.That(harness.Publisher?.IsUndone).IsFalse(); await Assert.That(harness.Repo.TagNames.Count).IsEqualTo(1); } @@ -260,7 +270,8 @@ public async Task DisposeAsync_AfterPushingUpdates_DeletesTheTag_AndUndoesThePub await Assert.That(harness.Repo.TagNames.Count).IsEqualTo(0); await Assert.That(harness.Repo.GetRemoteTagSha(tag)).IsNull(); await Assert.That(harness.Repo.GetRemoteTipSha()).IsEqualTo(initialSha); - await Assert.That(harness.Publisher!.IsUndone).IsTrue(); + var publisher = await Assert.That(harness.Publisher).IsNotNull(); + await Assert.That(publisher.IsUndone).IsTrue(); var notices = harness.Notices.ToArray(); await Assert.That(Array.IndexOf(notices, $"Deleted tag '{tag}' from 'origin'.")) .IsLessThan(Array.IndexOf(notices, "Undid the release commit.")); diff --git a/tests/Buildvana.Tool.Tests/SettingsHelpReflectionTests.cs b/tests/Buildvana.Tool.Tests/SettingsHelpReflectionTests.cs index 1ea6d99c..0008c4ec 100644 --- a/tests/Buildvana.Tool.Tests/SettingsHelpReflectionTests.cs +++ b/tests/Buildvana.Tool.Tests/SettingsHelpReflectionTests.cs @@ -33,8 +33,8 @@ private static string OptionLongNames(Type type) var longNames = type .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Select(static p => p.GetCustomAttribute()) - .Where(static a => a is not null) - .Select(static a => a!.LongNames[0]); + .OfType() + .Select(static a => a.LongNames[0]); return string.Join(",", longNames); } } diff --git a/tests/Buildvana.Tool.Tests/SolutionPinReaderTests.cs b/tests/Buildvana.Tool.Tests/SolutionPinReaderTests.cs index 5ffdc0b0..1b84b1bf 100644 --- a/tests/Buildvana.Tool.Tests/SolutionPinReaderTests.cs +++ b/tests/Buildvana.Tool.Tests/SolutionPinReaderTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using System.Text.Json; using Buildvana.Core; using Buildvana.Core.ConsoleOutput; @@ -87,7 +88,8 @@ public async Task ReadAsync_WhenEvaluationFails_ReportsAFailedStep() var reader = CreateReader(home, runner); var exception = await Assert.That(async () => await reader.ReadAsync([ProjectPath]).ConfigureAwait(false)) .Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(3); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + await Assert.That(exception.ExitCode).IsEqualTo(3); } [Test] @@ -98,13 +100,16 @@ public async Task ReadAsync_WithAnUnreadableDump_ReportsAFailedStep() var reader = CreateReader(home, runner); var exception = await Assert.That(async () => await reader.ReadAsync([ProjectPath]).ConfigureAwait(false)) .Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(3); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + await Assert.That(exception.ExitCode).IsEqualTo(3); } private static SolutionPinReader CreateReader(TempHome home, IProcessRunner runner) => new(home.Provider, runner, NullReporter.Instance); - private static Func, ProcessResult> WriteDumps(string projectName, params string[] targetFrameworks) + private static Func, ProcessResult> WriteDumps( + string projectName, + params string[] targetFrameworks) => (executable, args) => { var directory = DumpDirectoryOf(args); foreach (var targetFramework in targetFrameworks) @@ -142,5 +147,8 @@ private static void Write(string directory, string fileName, string content) } private static string DumpDirectoryOf(IReadOnlyList args) - => args.Single(static arg => arg.StartsWith(DumpDirectoryArgumentPrefix, StringComparison.Ordinal))[DumpDirectoryArgumentPrefix.Length..]; + { + var argument = args.Single(static arg => arg.StartsWith(DumpDirectoryArgumentPrefix, StringComparison.Ordinal)); + return argument[DumpDirectoryArgumentPrefix.Length..]; + } } diff --git a/tests/Buildvana.Tool.Tests/StubHttpMessageHandler.cs b/tests/Buildvana.Tool.Tests/StubHttpMessageHandler.cs index 35b68103..cbdab365 100644 --- a/tests/Buildvana.Tool.Tests/StubHttpMessageHandler.cs +++ b/tests/Buildvana.Tool.Tests/StubHttpMessageHandler.cs @@ -52,7 +52,7 @@ protected override async Task SendAsync( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - var url = request.RequestUri!.AbsoluteUri; + var url = request.RequestUri?.AbsoluteUri ?? throw new InvalidOperationException("The request has no URI."); var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); Requests.Add(new StubHttpRequest(request.Method, url, CollectHeaders(request), body)); diff --git a/tests/Buildvana.Tool.Tests/ToolDiagnosticsPageTests.cs b/tests/Buildvana.Tool.Tests/ToolDiagnosticsPageTests.cs index a6dda439..afb75b7c 100644 --- a/tests/Buildvana.Tool.Tests/ToolDiagnosticsPageTests.cs +++ b/tests/Buildvana.Tool.Tests/ToolDiagnosticsPageTests.cs @@ -13,10 +13,7 @@ internal sealed class ToolDiagnosticsPageTests { private const string SectionHeading = "Dependency management (1200-1299)"; - private static readonly string RepositoryRoot = typeof(ToolDiagnosticsPageTests).Assembly - .GetCustomAttributes() - .First(static attribute => attribute.Key == "RepositoryRoot") - .Value!; + private static readonly string RepositoryRoot = typeof(ToolDiagnosticsPageTests).Assembly.GetMetadata("RepositoryRoot"); [Test] public async Task DependencyManagementSection_ListsTheCodesOfDiagnosticCodes() @@ -25,7 +22,8 @@ public async Task DependencyManagementSection_ListsTheCodesOfDiagnosticCodes() var listed = page.GetTableColumn(SectionHeading, "Code"); var declared = typeof(DiagnosticCodes) .GetFields(BindingFlags.Public | BindingFlags.Static) - .Select(static field => (string)field.GetRawConstantValue()!); + .Select(static field => field.GetRawConstantValue()) + .OfType(); await Assert.That(Join(listed)).IsEqualTo(Join(declared)); } diff --git a/tests/Buildvana.Tool.Tests/ToolPinReaderTests.cs b/tests/Buildvana.Tool.Tests/ToolPinReaderTests.cs index c05b3ab0..4fd878da 100644 --- a/tests/Buildvana.Tool.Tests/ToolPinReaderTests.cs +++ b/tests/Buildvana.Tool.Tests/ToolPinReaderTests.cs @@ -71,9 +71,9 @@ public async Task Read_WithAManifestUnderDotConfig_NamesEachOneAndItsMove() home.WriteFile("docs/.config/dotnet-tools.json", """{ "tools": { } }"""); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning - var exception = await Assert.That(() => CreateReader(home).Read()).Throws(); + var exception = Assert.Throws(() => CreateReader(home).Read()); - await Assert.That(exception!.Message).Contains("git mv .config/dotnet-tools.json dotnet-tools.json"); + await Assert.That(exception.Message).Contains("git mv .config/dotnet-tools.json dotnet-tools.json"); await Assert.That(exception.Message).Contains("git mv docs/.config/dotnet-tools.json docs/dotnet-tools.json"); } @@ -88,9 +88,9 @@ public async Task Read_WithAManifestUnderDotConfigNextToOne_NamesTheMerge() home.WriteFile("docs/.config/dotnet-tools.json", """{ "tools": { } }"""); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning - var exception = await Assert.That(() => CreateReader(home).Read()).Throws(); + var exception = Assert.Throws(() => CreateReader(home).Read()); - await Assert.That(exception!.Message) + await Assert.That(exception.Message) .Contains("merge the tools of .config/dotnet-tools.json into dotnet-tools.json, then delete .config/dotnet-tools.json"); await Assert.That(exception.Message).Contains("git mv docs/.config/dotnet-tools.json docs/dotnet-tools.json"); } @@ -105,9 +105,9 @@ public async Task Read_WithAManifestUnderDotConfigAndAnUnparseableOne_NamesTheMo home.WriteFile("dotnet-tools.json", "{ not json"); // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning - var exception = await Assert.That(() => CreateReader(home).Read()).Throws(); - - await Assert.That(exception!.Message).Contains("git mv docs/.config/dotnet-tools.json docs/dotnet-tools.json"); + await Assert.That(() => CreateReader(home).Read()) + .Throws() + .WithMessageContaining("git mv docs/.config/dotnet-tools.json docs/dotnet-tools.json", StringComparison.Ordinal); } // The bv entry of the home directory's manifest moves with the family, so bv dependencies never sees it. diff --git a/tests/Buildvana.Tool.Tests/ToolPinUpdaterTests.cs b/tests/Buildvana.Tool.Tests/ToolPinUpdaterTests.cs index 4662eacb..86d91038 100644 --- a/tests/Buildvana.Tool.Tests/ToolPinUpdaterTests.cs +++ b/tests/Buildvana.Tool.Tests/ToolPinUpdaterTests.cs @@ -1,6 +1,7 @@ // Copyright (C) Tenacom and Contributors. Licensed under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Diagnostics; using Buildvana.Core; using Buildvana.Core.Configuration; using Buildvana.Core.ConsoleOutput; @@ -64,7 +65,8 @@ public async Task UpdateAsync_WhenAnUpdateFails_StopsThere() // ReSharper disable once AccessToDisposedClosure // the assertion invokes the delegate before returning var exception = await Assert.That(async () => await UpdateAsync(home, runner, pins).ConfigureAwait(false)) .Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.ExternalProgramFailed); + Debug.Assert(exception is not null, "Throws returns null only inside Assert.Multiple."); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.ExternalProgramFailed); await Assert.That(runner.Runs.Count).IsEqualTo(1); } diff --git a/tests/Buildvana.Tool.Tests/ToolResolverCacheProbeTests.cs b/tests/Buildvana.Tool.Tests/ToolResolverCacheProbeTests.cs index 575986a1..025f1934 100644 --- a/tests/Buildvana.Tool.Tests/ToolResolverCacheProbeTests.cs +++ b/tests/Buildvana.Tool.Tests/ToolResolverCacheProbeTests.cs @@ -105,10 +105,8 @@ private static void WriteCacheFile(TempHome home, string content) private static string CreateExecutableFile(TempHome home) { - var path = Path.Combine(home.RootPath, "tools", "Buildvana.Tool.dll"); - _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.WriteAllText(path, string.Empty); - return path; + home.WriteFile("tools/Buildvana.Tool.dll", string.Empty); + return home.GetFullPath("tools/Buildvana.Tool.dll"); } // The real cache row shape; the executable path goes through the JSON serializer because Windows paths diff --git a/tests/Buildvana.Tool.Tests/UpdatePolicyEngineTests.cs b/tests/Buildvana.Tool.Tests/UpdatePolicyEngineTests.cs index 1a13beca..d86463a9 100644 --- a/tests/Buildvana.Tool.Tests/UpdatePolicyEngineTests.cs +++ b/tests/Buildvana.Tool.Tests/UpdatePolicyEngineTests.cs @@ -251,6 +251,7 @@ public async Task SelectPackage_WithUndefinedKind_Holds() [Test] public async Task SelectPackage_WithNullCurrent_Throws() { + // ! Null on purpose: testing null detection static TargetSelection Act() => UpdatePolicyEngine.Select(null!, Versions("1.0.0"), new(PackageUpdatePolicyKind.Minor, false)); @@ -260,6 +261,7 @@ static TargetSelection Act() [Test] public async Task SelectPackage_WithNullCandidates_Throws() { + // ! Null on purpose: testing null detection static TargetSelection Act() => UpdatePolicyEngine.Select( NuGetVersion.Parse("1.0.0"), @@ -381,6 +383,7 @@ public async Task SelectNetSdk_WithUndefinedKind_Holds() [Test] public async Task SelectNetSdk_WithNullCurrent_Throws() { + // ! Null on purpose: testing null detection static TargetSelection Act() => UpdatePolicyEngine.Select(null!, [Lts("10.0.402")], new(NetSdkUpdatePolicyKind.Major, false)); @@ -390,6 +393,7 @@ static TargetSelection Act() [Test] public async Task SelectNetSdk_WithNullCandidates_Throws() { + // ! Null on purpose: testing null detection static TargetSelection Act() => UpdatePolicyEngine.Select( NuGetVersion.Parse("10.0.402"), diff --git a/tests/Buildvana.Tool.Tests/UrlMatchingReleaseRemoteResolverTests.cs b/tests/Buildvana.Tool.Tests/UrlMatchingReleaseRemoteResolverTests.cs index a7f1b944..622e2af0 100644 --- a/tests/Buildvana.Tool.Tests/UrlMatchingReleaseRemoteResolverTests.cs +++ b/tests/Buildvana.Tool.Tests/UrlMatchingReleaseRemoteResolverTests.cs @@ -39,8 +39,8 @@ public async Task Resolve_WithAnSshRemoteAlone_Throws() using var repo = CreateRepo(("origin", "ssh://git@github.com/Tenacom/Buildvana.git")); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before repo is disposed - var exception = await Assert.That(() => Resolve(repo, RepositoryUrl)).Throws(); - await Assert.That(exception!.Message).Contains("Remote 'origin' names repository.url"); + var exception = Assert.Throws(() => Resolve(repo, RepositoryUrl)); + await Assert.That(exception.Message).Contains("Remote 'origin' names repository.url"); await Assert.That(exception.Message).Contains("HTTP and HTTPS"); } @@ -50,8 +50,8 @@ public async Task Resolve_WithoutAMatchingRemote_ThrowsNamingTheRemotes() using var repo = CreateRepo(("origin", "https://github.com/someone/Buildvana")); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before repo is disposed - var exception = await Assert.That(() => Resolve(repo, RepositoryUrl)).Throws(); - await Assert.That(exception!.Message).Contains($"No remote of the clone names repository.url '{RepositoryUrl}'"); + var exception = Assert.Throws(() => Resolve(repo, RepositoryUrl)); + await Assert.That(exception.Message).Contains($"No remote of the clone names repository.url '{RepositoryUrl}'"); await Assert.That(exception.Message).Contains("'origin' (https://github.com/someone/Buildvana)"); } @@ -61,8 +61,9 @@ public async Task Resolve_WithoutRemotes_Throws() using var repo = CreateRepo(); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before repo is disposed - var exception = await Assert.That(() => Resolve(repo, RepositoryUrl)).Throws(); - await Assert.That(exception!.Message).Contains("The clone has no remotes."); + await Assert.That(() => Resolve(repo, RepositoryUrl)) + .Throws() + .WithMessageContaining("The clone has no remotes.", StringComparison.Ordinal); } [Test] @@ -71,8 +72,9 @@ public async Task Resolve_WithoutRepositoryUrl_Throws() using var repo = CreateRepo(("origin", RepositoryUrl)); // ReSharper disable once AccessToDisposedClosure // False positive: the assertion invokes the lambda before repo is disposed - var exception = await Assert.That(() => Resolve(repo, null)).Throws(); - await Assert.That(exception!.Message).Contains("repository.url"); + await Assert.That(() => Resolve(repo, null)) + .Throws() + .WithMessageContaining("repository.url", StringComparison.Ordinal); } private static TempGitRepo CreateRepo(params (string Name, string Url)[] remotes) diff --git a/tests/Buildvana.Tool.Tests/UsageExitCodeTests.cs b/tests/Buildvana.Tool.Tests/UsageExitCodeTests.cs index 99a72b4a..d6bcb9bc 100644 --- a/tests/Buildvana.Tool.Tests/UsageExitCodeTests.cs +++ b/tests/Buildvana.Tool.Tests/UsageExitCodeTests.cs @@ -58,17 +58,25 @@ public Task InvalidChangeArgument_ExitsWithUsageCode() => AssertUsageExitCode(() => VersionAdvanceSettings.Parse(["bogus"], []).ResolveChange()); // The dispatch path of Program, up to the point where the command would run: resolve the command, then - // validate what was given to it. + // validate what was given to it. Where Program would print help instead, nothing throws, and the test fails. private static void ParseCommandLine(string commandLine) { var parsed = CliArgSplitter.Split(commandLine.Split(' ')); - var (node, positionals) = CommandRegistry.Resolve(parsed.Subcommand!, parsed.Positionals); - CommandArgumentValidator.Validate(node.Command!, parsed, positionals); + if (parsed.Subcommand is null) + { + return; + } + + var (node, positionals) = CommandRegistry.Resolve(parsed.Subcommand, parsed.Positionals); + if (node.Command is { } command) + { + CommandArgumentValidator.Validate(command, parsed, positionals); + } } private static async Task AssertUsageExitCode(Action action) { - var exception = await Assert.That(action).Throws(); - await Assert.That(exception!.ExitCode).IsEqualTo(ExitCodes.Usage); + var exception = Assert.Throws(action); + await Assert.That(exception.ExitCode).IsEqualTo(ExitCodes.Usage); } } diff --git a/tests/Buildvana.Tool.Tests/VerbosityParserTests.cs b/tests/Buildvana.Tool.Tests/VerbosityParserTests.cs index d06ece7b..1f299484 100644 --- a/tests/Buildvana.Tool.Tests/VerbosityParserTests.cs +++ b/tests/Buildvana.Tool.Tests/VerbosityParserTests.cs @@ -29,14 +29,17 @@ public async Task Parse_KnownLevel_ReturnsVerbosity(string raw, Verbosity expect [Test] public async Task Parse_UnknownLevel_FailsNamingValueAndAlternatives() { - var exception = await Assert.That(() => VerbosityParser.Parse("loud")).Throws(); - await Assert.That(exception!.Message) - .IsEqualTo("Unknown verbosity level 'loud'. Use one of: [q]uiet, [m]inimal, [n]ormal, [d]etailed, [diag]nostic."); + await Assert.That(() => VerbosityParser.Parse("loud")) + .Throws() + .WithMessage( + "Unknown verbosity level 'loud'. Use one of: [q]uiet, [m]inimal, [n]ormal, [d]etailed, [diag]nostic.", + StringComparison.Ordinal); } [Test] public async Task Parse_Null_ThrowsArgumentNullException() { + // ! Null on purpose: testing null detection await Assert.That(() => VerbosityParser.Parse(null!)).Throws(); } }