diff --git a/.github/agents/test.agent.md b/.github/agents/test.agent.md new file mode 100644 index 0000000..35572e3 --- /dev/null +++ b/.github/agents/test.agent.md @@ -0,0 +1,82 @@ +--- +description: "Use when writing, fixing, or reviewing tests for WB.Configuration; run tests, analyze failures, and keep changes confined to /tests/." +name: "Test Writer Agent" +tools: [read, search, execute, edit, todo] +user-invocable: true +--- + +You are a test writer. + +## Constraints +- ONLY write, edit, or delete files under /tests/. +- NEVER modify source code under /src/ or any non-test files. +- If failure analysis reveals the root cause is a defect in /src/, do not modify source code. Instead, document the finding in your output, mark the test with a comment noting the upstream defect, and leave the test in place so the failure is visible. +- NEVER remove failing tests just to make the suite pass. +- ALWAYS preserve or improve test clarity, determinism, and coverage. + +## Responsibilities +- Write tests for this codebase. +- After making changes, first run only the test class(es) you modified. If those pass, run all tests under /tests/ to check for regressions. +- If the test runner fails to execute (e.g., build errors, missing tooling), report the exact error output, do not assume test results, and stop further edits until the environment issue is resolved. +- Analyze failures and explain the most likely cause with concrete evidence from the test output. +- Prefer the smallest test change that proves the behavior. + +## Test Structure Standards +- Follow the existing arrange, act, assert style used in this repository. +- Prefer one behavior per test method. +- Use descriptive test names that state the expected outcome. +- Keep assertions focused and readable. +- Mirror the repository's current style of `AwesomeAssertions`, `[Test]`, and explicit namespaces. +- Use one sub directory per class under test. +- Use `MethodTests` subdirectories for method-specific tests. +- Use `PropertyTests` subdirectories for property-specific tests. + +## Good Test Structure Examples +```csharp +[Test] +public void ShouldReturnValue_WhenKeyExists() +{ + // Arrange + IConfigurationNode configurationNode = new ConfigurationNode(new JsonObject + { + ["key"] = "value" + }); + + // Act + string value = configurationNode.GetRequired("key"); + + // Assert + value.Should().Be("value"); +} +``` + +```csharp +[Test] +public void ShouldThrowArgumentOutOfRangeException_WhenIndexDoesNotExist() +{ + // Arrange + IConfigurationNode configurationNode = new ConfigurationNode(new JsonArray()); + + // Act + Action act = () => configurationNode.GetRequired(0); + + // Assert + act.Should().Throw(); +} +``` + +## Approach +1. Inspect the nearest existing test file and match its naming and structure. +2. Add or adjust tests only inside /tests/. +3. Run the narrowest relevant test set first, then broaden only if needed. +4. Report what passed, what failed, and what the failures imply. + +## Execute Tests +- Tests are run with Microsoft.Testing.Platform. +- Use `dotnet run` with appropriate filters to run specific test classes or methods. + +## Output Format +- Summarize the test files changed. +- List the test commands you ran. +- Report failures with their likely cause. +- State any remaining risk if coverage is still incomplete. \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d293543 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# AGENTS.md + +## Commit Message Rules + +- Use Conventional Commits +- Imperative mood +- Max 72 chars in subject +- Wrap body at 100 chars diff --git a/src/Configuration.cs b/src/Configuration.cs index a6bf901..3841ef7 100644 --- a/src/Configuration.cs +++ b/src/Configuration.cs @@ -17,6 +17,32 @@ public sealed class Configuration : IConfiguration, IConfigurationNode private ConfigurationNode? configurationNode; + public IConfigurationNode this[int index] + { + get + { + if (configurationNode is null) + { + return new ConfigurationNode(null); + } + + return configurationNode[index]; + } + } + + public IConfigurationNode this[string key] + { + get + { + if (configurationNode is null) + { + return new ConfigurationNode(null); + } + + return configurationNode[key]; + } + } + // ┌─────────────────────────────────────────────────────────────────────────────┐ // │ Public Methods │ // └─────────────────────────────────────────────────────────────────────────────┘ diff --git a/src/ConfigurationNode.cs b/src/ConfigurationNode.cs index a60eea1..a478f52 100644 --- a/src/ConfigurationNode.cs +++ b/src/ConfigurationNode.cs @@ -11,6 +11,41 @@ internal sealed class ConfigurationNode(JsonNode? jsonNode) : IConfigurationNode // └─────────────────────────────────────────────────────────────────────────────┘ private readonly JsonNode? jsonNode = jsonNode; + // ┌─────────────────────────────────────────────────────────────────────────────┐ + // │ Public Indexers │ + // └─────────────────────────────────────────────────────────────────────────────┘ + + /// + public IConfigurationNode this[string key] + { + get + { + if (jsonNode is JsonObject jsonObject && jsonObject.TryGetPropertyValue(key, out JsonNode? childNode)) + { + return new ConfigurationNode(childNode); + } + else + { + return new ConfigurationNode(null); + } + } + } + + public IConfigurationNode this[int index] + { + get + { + if (jsonNode is JsonArray jsonArray && index >= 0 && index < jsonArray.Count) + { + return new ConfigurationNode(jsonArray[index]); + } + else + { + return new ConfigurationNode(null); + } + } + } + // ┌─────────────────────────────────────────────────────────────────────────────┐ // │ Public Methods │ // └─────────────────────────────────────────────────────────────────────────────┘ @@ -18,7 +53,7 @@ internal sealed class ConfigurationNode(JsonNode? jsonNode) : IConfigurationNode /// public bool TryGet(string key, out T? value) { - if (jsonNode is JsonObject obj && obj.TryGetPropertyValue(key, out JsonNode? jsonValue)) + if (jsonNode is JsonObject jsonObject && jsonObject.TryGetPropertyValue(key, out JsonNode? jsonValue)) { value = jsonValue.Deserialize(); @@ -35,9 +70,9 @@ public bool TryGet(string key, out T? value) /// public bool TryGet(int index, out T? value) { - if (jsonNode is JsonArray arr && index >= 0 && index < arr.Count) + if (jsonNode is JsonArray jsonArray && index >= 0 && index < jsonArray.Count) { - value = arr[index].Deserialize(); + value = jsonArray[index].Deserialize(); return true; } diff --git a/src/IConfigurationNode.cs b/src/IConfigurationNode.cs index 91cba47..5533187 100644 --- a/src/IConfigurationNode.cs +++ b/src/IConfigurationNode.cs @@ -8,6 +8,24 @@ namespace WB.Configuration; /// public interface IConfigurationNode { + // ┌─────────────────────────────────────────────────────────────────────────────┐ + // │ Public Indexers │ + // └─────────────────────────────────────────────────────────────────────────────┘ + + /// + /// Gets a child configuration node associated with the specified key. If the key does not exist, returns an empty configuration node that will return default values for any queries. + /// + /// The key associated with the child configuration node. + /// A child configuration node associated with the specified key, or an empty configuration node if the key does not exist. + public IConfigurationNode this[string key] { get; } + + /// + /// Gets a child configuration node at the specified index. If the index is out of range, returns an empty configuration node that will return default values for any queries. + /// + /// The index of the child configuration node. + /// A child configuration node at the specified index, or an empty configuration node if the index is out of range. + public IConfigurationNode this[int index] { get; } + // ┌─────────────────────────────────────────────────────────────────────────────┐ // │ Public Methods │ // └─────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/ConfigurationNodeTests/src/IndexerTests/IntIndexTests.cs b/tests/ConfigurationNodeTests/src/IndexerTests/IntIndexTests.cs new file mode 100644 index 0000000..b537685 --- /dev/null +++ b/tests/ConfigurationNodeTests/src/IndexerTests/IntIndexTests.cs @@ -0,0 +1,67 @@ +using System.Text.Json.Nodes; +using AwesomeAssertions; +using WB.Configuration; + +namespace ConfigurationNodeTests.IndexerTests.IntIndexTests; + +public sealed class IntIndexTests +{ + [Test] + public void ShouldReturnChildConfigurationNode_WhenIndexExists() + { + // Arrange + ConfigurationNode configurationNode = new(new JsonArray + { + new JsonObject + { + ["key"] = "value" + } + }); + + // Act + IConfigurationNode childNode = configurationNode[0]; + string? value = childNode.Get("key"); + + // Assert + value.Should().Be("value", because: "the index exists and should return the child configuration node"); + } + + [Test] + public void ShouldReturnEmptyConfigurationNode_WhenIndexDoesNotExist() + { + // Arrange + ConfigurationNode configurationNode = new(new JsonArray + { + new JsonObject + { + ["key"] = "value" + } + }); + + // Act + IConfigurationNode childNode = configurationNode[1]; + bool result = childNode.TryGet("key", out string? value); + + // Assert + result.Should().BeFalse(because: "the index does not exist in the current configuration node"); + value.Should().BeNull(because: "an empty configuration node returns default values for any query"); + } + + [Test] + public void ShouldReturnEmptyConfigurationNode_WhenCurrentNodeIsNotArray() + { + // Arrange + ConfigurationNode configurationNode = new(new JsonObject + { + ["key"] = "value" + }); + + // Act + IConfigurationNode childNode = configurationNode[0]; + bool result = childNode.TryGet("key", out string? value); + + // Assert + result.Should().BeFalse(because: "int index lookup requires the current node to be a JSON array"); + value.Should().BeNull(because: "non-array nodes return an empty configuration node for int index lookups"); + } +} diff --git a/tests/ConfigurationNodeTests/src/IndexerTests/StringIndexTests.cs b/tests/ConfigurationNodeTests/src/IndexerTests/StringIndexTests.cs new file mode 100644 index 0000000..d037436 --- /dev/null +++ b/tests/ConfigurationNodeTests/src/IndexerTests/StringIndexTests.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Nodes; +using AwesomeAssertions; +using WB.Configuration; + +namespace ConfigurationNodeTests.IndexerTests.StringIndexTests; + +public sealed class StringIndexTests +{ + [Test] + public void ShouldReturnChildConfigurationNode_WhenKeyExists() + { + // Arrange + ConfigurationNode configurationNode = new(new JsonObject + { + ["section"] = new JsonObject + { + ["key"] = "value" + } + }); + + // Act + IConfigurationNode childNode = configurationNode["section"]; + string? value = childNode.Get("key"); + + // Assert + value.Should().Be("value", because: "the key exists and should return the child configuration node"); + } + + [Test] + public void ShouldReturnEmptyConfigurationNode_WhenKeyDoesNotExist() + { + // Arrange + ConfigurationNode configurationNode = new(new JsonObject + { + ["section"] = new JsonObject + { + ["key"] = "value" + } + }); + + // Act + IConfigurationNode childNode = configurationNode["nonexistentSection"]; + bool result = childNode.TryGet("key", out string? value); + + // Assert + result.Should().BeFalse(because: "the key does not exist in the current configuration node"); + value.Should().BeNull(because: "an empty configuration node returns default values for any query"); + } + + [Test] + public void ShouldReturnEmptyConfigurationNode_WhenCurrentNodeIsNotObject() + { + // Arrange + ConfigurationNode configurationNode = new(new JsonArray("value")); + + // Act + IConfigurationNode childNode = configurationNode["section"]; + bool result = childNode.TryGet("key", out string? value); + + // Assert + result.Should().BeFalse(because: "string index lookup requires the current node to be a JSON object"); + value.Should().BeNull(because: "non-object nodes return an empty configuration node for string index lookups"); + } +}