diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7c37dd13f0..7751efff58 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -107,6 +107,9 @@ /plugins/dotnet-experimental/skills/exp-assertion-quality/ @dotnet/dotnet-testing /tests/dotnet-experimental/exp-assertion-quality/ @dotnet/dotnet-testing +/plugins/dotnet-experimental/skills/exp-test-gap-analysis/ @dotnet/dotnet-testing +/tests/dotnet-experimental/exp-test-gap-analysis/ @dotnet/dotnet-testing + /plugins/dotnet-experimental/skills/exp-simd-vectorization/ @jeffschw @artl93 /tests/dotnet-experimental/exp-simd-vectorization/ @jeffschw @artl93 diff --git a/plugins/dotnet-experimental/skills/exp-test-gap-analysis/SKILL.md b/plugins/dotnet-experimental/skills/exp-test-gap-analysis/SKILL.md new file mode 100644 index 0000000000..2735bba2ed --- /dev/null +++ b/plugins/dotnet-experimental/skills/exp-test-gap-analysis/SKILL.md @@ -0,0 +1,207 @@ +--- +name: exp-test-gap-analysis +description: "Performs pseudo-mutation analysis on .NET production code to find gaps in existing test suites. Use when the user asks to find weak tests, discover untested edge cases, check if tests would catch a bug, or evaluate test effectiveness through mutation-style reasoning. Analyzes production code for mutation points (boundary conditions, boolean flips, null returns, exception removal, arithmetic changes) and checks whether existing tests would detect each mutation. Works with MSTest, xUnit, NUnit, and TUnit. DO NOT USE FOR: writing new tests (use writing-mstest-tests), detecting test anti-patterns (use test-anti-patterns), measuring assertion diversity (use exp-assertion-quality), or running actual mutation testing tools." +--- + +# Test Gap Analysis via Pseudo-Mutation + +Analyze .NET production code by reasoning about hypothetical mutations and checking whether existing tests would catch them. This reveals blind spots where tests pass but would continue to pass even if the code were broken. + +## Why Pseudo-Mutation Matters + +Code coverage tells you what code ran during tests. It does **not** tell you whether tests would fail if that code were wrong. A method can have 100% line coverage but zero tests that would catch a sign flip, an off-by-one error, or a removed null check. + +Pseudo-mutation analysis asks: _"If I changed this line, would any test fail?"_ When the answer is "no," you've found a test gap. + +| Coverage Metric | What It Measures | What It Misses | +|----------------|-----------------|----------------| +| Line coverage | Which lines executed | Whether assertions verify those lines' behavior | +| Branch coverage | Which branches taken | Whether both branches produce different asserted outcomes | +| **Mutation score** | Whether tests detect code changes | Nothing — this is the gold standard | + +This skill performs **static pseudo-mutation** — reasoning about mutations without actually running them — to approximate mutation testing at the speed of code review. + +## When to Use + +- User asks "would my tests catch a bug in this code?" +- User wants to find weak or shallow tests +- User wants to evaluate test effectiveness beyond coverage +- User asks for mutation testing or mutation analysis +- User asks "where are my tests blind?" +- User wants to prioritize which tests to strengthen + +## When Not to Use + +- User wants to write new tests from scratch (use `writing-mstest-tests`) +- User wants to detect test anti-patterns like flakiness or poor naming (use `test-anti-patterns`) +- User wants to measure assertion variety (use `exp-assertion-quality`) +- User wants to run an actual mutation testing framework like Stryker (help them directly) +- User only wants code coverage numbers (out of scope) + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Production code | Yes | The source files to analyze for mutation points | +| Test code | Yes | The test files that cover the production code | +| Focus area | No | A specific mutation category or code region to focus on | + +## Workflow + +### Step 1: Gather production and test code + +Read both the production code and its corresponding test files. If the user points to a directory, identify production/test pairs by convention (e.g., `Calculator.cs` tested by `CalculatorTests.cs`). + +Establish which production methods are exercised by which test methods — trace this through method calls in test code, setup, and helper methods. + +### Step 2: Identify mutation points + +Scan the production code and annotate every location where a mutation could reveal a test gap. Use the mutation catalog below. + +#### Boundary Mutations + +| Original | Mutation | What it tests | +|----------|----------|---------------| +| `<` | `<=` | Off-by-one at upper bound | +| `>` | `>=` | Off-by-one at lower bound | +| `<=` | `<` | Boundary inclusion | +| `>=` | `>` | Boundary inclusion | +| `== 0` | `== 1` or `<= 0` | Zero-boundary handling | +| `i < length` | `i < length - 1` or `i <= length` | Loop boundary | +| `index + 1` | `index` or `index + 2` | Index arithmetic | + +#### Boolean and Logic Mutations + +| Original | Mutation | What it tests | +|----------|----------|---------------| +| `&&` | `\|\|` | Condition independence | +| `\|\|` | `&&` | Condition necessity | +| `!condition` | `condition` | Negation correctness | +| `if (x)` | `if (!x)` | Branch selection | +| `true` (constant) | `false` | Hardcoded assumption | +| `flag \|\| other` | `other` | Short-circuit first operand | + +#### Return Value Mutations + +| Original | Mutation | What it tests | +|----------|----------|---------------| +| `return result` | `return null` | Null handling downstream | +| `return result` | `return default` | Default value handling | +| `return true` | `return false` | Boolean return verification | +| `return list` | `return new List()` | Empty collection handling | +| `return count` | `return 0` or `return count + 1` | Numeric return verification | +| `return string` | `return ""` or `return null` | String return verification | + +#### Exception Removal Mutations + +| Original | Mutation | What it tests | +|----------|----------|---------------| +| `throw new ArgumentNullException(...)` | _(remove entire throw)_ | Guard clause verification | +| `throw new InvalidOperationException(...)` | _(remove entire throw)_ | State validation testing | +| `if (x == null) throw ...` | _(remove entire guard)_ | Null guard testing | +| `if (!IsValid()) throw ...` | _(remove entire check)_ | Validation testing | + +#### Arithmetic Mutations + +| Original | Mutation | What it tests | +|----------|----------|---------------| +| `a + b` | `a - b` | Addition correctness | +| `a - b` | `a + b` | Subtraction correctness | +| `a * b` | `a / b` | Multiplication correctness | +| `a / b` | `a * b` | Division correctness | +| `a % b` | `a / b` | Modulo correctness | +| `x++` | `x--` | Increment direction | +| `-value` | `value` | Sign flip | + +#### Null-Check Removal Mutations + +| Original | Mutation | What it tests | +|----------|----------|---------------| +| `if (x == null) return ...` | _(remove null check)_ | Null path coverage | +| `if (x != null) { ... }` | _(always enter block)_ | Null guard necessity | +| `x ?? defaultValue` | `x` | Null coalescing coverage | +| `x?.Method()` | `x.Method()` | Null-conditional coverage | +| `x!` | `x` | Null-forgiving operator necessity | + +### Step 3: Evaluate each mutation against tests + +For each identified mutation point, reason about whether existing tests would detect the change: + +1. **Find covering tests** — Which test methods exercise the mutated line? Follow call chains through helpers and setup methods. +2. **Check assertion relevance** — Do those tests assert something that would change if the mutation were applied? A test that calls the method but only asserts an unrelated property would NOT catch the mutation. +3. **Classify the mutation** as: + +| Verdict | Meaning | Action | +|---------|---------|--------| +| **Killed** | At least one test would fail if this mutation were applied | No action needed — tests are effective here | +| **Survived** | No test would fail — the mutation would go undetected | This is a test gap — recommend a test improvement | +| **No coverage** | No test exercises this code path at all | Worse than survived — the code is untested | +| **Equivalent** | The mutation produces identical behavior (e.g., `x * 1` → `x / 1`) | Skip — not a real mutation | + +### Step 4: Calibrate findings + +Before reporting, apply these calibration rules: + +- **Don't flag trivial code.** Simple property getters (`return _name;`), auto-properties, and boilerplate don't need mutation analysis. Focus on logic, conditions, calculations, and error handling. +- **Consider defensive depth.** If a null guard has a survived mutation but the caller also checks for null, note the redundancy but rate it lower priority. +- **Equivalent mutations are not gaps.** If changing `>=` to `>` doesn't alter behavior because the `==` case is impossible given the domain, mark it Equivalent and skip. +- **Private methods reached through public API are valid targets.** Trace through the call chain — a private method called from a tested public method may still have survived mutations if the test doesn't assert the specific behavior affected. +- **Rate by risk, not count.** A single survived mutation in payment calculation logic is more important than five survived mutations in logging code. + +### Step 5: Report findings + +Present the analysis in this structure: + +1. **Summary** — Overall mutation score and key findings: + ``` + | Metric | Value | + |---------------------|----------| + | Mutation points | 42 | + | Killed | 28 (67%) | + | Survived | 10 (24%) | + | No coverage | 2 (5%) | + | Equivalent (skipped) | 2 (5%) | + ``` + +2. **Survived Mutations (Test Gaps)** — For each survived mutation, report: + - **Location**: File, method, line + - **Mutation category**: Boundary / Boolean / Return value / Exception / Arithmetic / Null-check + - **Original code**: The current code + - **Hypothetical mutation**: What would change + - **Why it survives**: Which tests cover this code and why their assertions miss it + - **Recommended fix**: A concrete test assertion or new test case that would kill this mutation + + Group by priority: high-risk survived mutations first (business logic, calculations, security checks), lower-risk last (logging, formatting). + +3. **No-Coverage Zones** — Code paths that no test reaches at all. These are worse than survived mutations. + +4. **Killed Mutations (Strengths)** — Briefly note areas where tests are effective. Highlight well-tested methods and strong assertion patterns. Don't enumerate every killed mutation — summarize. + +5. **Recommendations** — Prioritized list: + - Which survived mutations to address first (by risk) + - Specific test methods to add or strengthen + - Patterns the team can adopt to prevent future gaps (e.g., always test boundary values, always assert exception types) + +## Validation + +- [ ] Every mutation point was classified (Killed / Survived / No coverage / Equivalent) +- [ ] Every survived mutation includes the original code, the hypothetical change, and why tests miss it +- [ ] Every survived mutation includes a concrete recommended fix (a test assertion or test case) +- [ ] Equivalent mutations are correctly identified and excluded from the score +- [ ] Trivial code (simple getters, auto-properties) is excluded from analysis +- [ ] Findings are prioritized by risk, not just listed in source order +- [ ] Report includes strengths (killed mutations) alongside gaps +- [ ] Mutation categories are correctly labeled + +## Common Pitfalls + +| Pitfall | Solution | +|---------|----------| +| Analyzing trivial code | Skip auto-properties, simple getters, and boilerplate — focus on logic | +| Reporting equivalent mutations as gaps | If the mutation doesn't change behavior, it's not a gap — mark Equivalent | +| Ignoring call chains | A private helper called from a tested public method is reachable — trace the chain | +| Over-counting mutations in generated code | Skip auto-generated code, designer files, and migration files | +| Recommending a new test for every survived mutation | Multiple survived mutations in the same method often share a single missing test — recommend one test that kills several | +| Ignoring production context | A survived mutation in `ToString()` formatting is less important than one in `CalculateTotal()` — prioritize by business risk | +| Claiming 100% kill rate is required | Some mutations in low-risk code are acceptable to leave — acknowledge this in the report | +| Not considering integration with other skills | If gaps are found, mention that `writing-mstest-tests` can help write the missing tests, and `test-anti-patterns` can audit existing test quality | diff --git a/tests/dotnet-experimental/exp-test-gap-analysis/eval.yaml b/tests/dotnet-experimental/exp-test-gap-analysis/eval.yaml new file mode 100644 index 0000000000..46063117e6 --- /dev/null +++ b/tests/dotnet-experimental/exp-test-gap-analysis/eval.yaml @@ -0,0 +1,143 @@ +scenarios: + # ========================================================================== + # Scenario 1: Boundary mutation gaps in pricing logic + # ========================================================================== + + - name: "Find boundary mutation gaps in tiered discount and shipping logic" + prompt: | + I want to know if my tests are actually strong enough to catch subtle bugs. + Look at my PricingEngine code and its tests — could a small change like an + off-by-one error or a flipped comparison slip through without any test failing? + setup: + files: + - path: "PricingEngine/PricingEngine.csproj" + source: "fixtures/boundary-gaps/PricingEngine/PricingEngine.csproj" + - path: "PricingEngine/DiscountCalculator.cs" + source: "fixtures/boundary-gaps/PricingEngine/DiscountCalculator.cs" + - path: "PricingEngine.Tests/PricingEngine.Tests.csproj" + source: "fixtures/boundary-gaps/PricingEngine.Tests/PricingEngine.Tests.csproj" + - path: "PricingEngine.Tests/DiscountCalculatorTests.cs" + source: "fixtures/boundary-gaps/PricingEngine.Tests/DiscountCalculatorTests.cs" + assertions: + - type: "output_matches" + pattern: "(boundar|off.by.one|>=.*>|>.*>=|threshold|exact.*(value|boundary|point)|edge)" + - type: "output_matches" + pattern: "(1000|500|100|250)" + - type: "output_matches" + pattern: "(surviv|not.*caught|not.*detected|would.*pass|miss|blind|gap|weak)" + - type: "output_matches" + pattern: "(express|heavy|10.*kg|coupon|minimum|floor|1\\.00)" + - type: "exit_success" + rubric: + - "Identified that no test exercises the exact discount tier boundaries (1000, 500, 100) — mutations like >= to > at those points would survive" + - "Identified that the free shipping boundary at 250 is not tested — order of exactly 250 is not covered" + - "Identified that the express shipping surcharge is never tested" + - "Identified that the heavy item fee boundary (weight > 10kg) is not tested" + - "Identified that the coupon minimum floor (total cannot go below 1.00) is not tested" + - "Recommended concrete test cases that would kill the survived mutations" + timeout: 300 + + # ========================================================================== + # Scenario 2: Boolean/logic and null-check mutation gaps in access control + # ========================================================================== + + - name: "Find logic and null-check mutation gaps in access control code" + prompt: | + I have an access control system and I'm worried the tests might not catch + all security-related bugs. Can you analyze whether a subtle logic change + in the permission checks would go undetected by the current tests? + setup: + files: + - path: "AccessControl/AccessControl.csproj" + source: "fixtures/logic-gaps/AccessControl/AccessControl.csproj" + - path: "AccessControl/AccessChecker.cs" + source: "fixtures/logic-gaps/AccessControl/AccessChecker.cs" + - path: "AccessControl.Tests/AccessControl.Tests.csproj" + source: "fixtures/logic-gaps/AccessControl.Tests/AccessControl.Tests.csproj" + - path: "AccessControl.Tests/AccessCheckerTests.cs" + source: "fixtures/logic-gaps/AccessControl.Tests/AccessCheckerTests.cs" + assertions: + - type: "output_matches" + pattern: "(Guest|guest|denied|no.access)" + - type: "output_matches" + pattern: "(write|CanWrite|writeAccess)" + - type: "output_matches" + pattern: "(null|empty|token|elevat)" + - type: "output_matches" + pattern: "(surviv|not.*caught|not.*detected|gap|miss|blind|weak)" + - type: "exit_success" + rubric: + - "Identified that Guest access denial is never tested — removing the default deny branch would survive" + - "Identified that User's CanWrite=false is never asserted — flipping it to true would survive" + - "Identified that CanPerform is only tested with writeAccess=false — the write path is not verified" + - "Identified that ElevateRole is not tested with null or empty tokens — removing the null guard would survive" + - "Identified that Editor on system resources getting read-only (CanWrite=false) is not tested" + - "Recommended concrete test cases targeting the specific survived mutations" + timeout: 300 + + # ========================================================================== + # Scenario 3: Recognize well-tested code with high mutation kill rate + # ========================================================================== + + - name: "Acknowledge well-tested code with few surviving mutations" + prompt: | + Can you check if my inventory management tests would actually catch + bugs if someone introduced a subtle mistake in the code? + setup: + files: + - path: "Inventory/Inventory.csproj" + source: "fixtures/well-tested/Inventory/Inventory.csproj" + - path: "Inventory/StockManager.cs" + source: "fixtures/well-tested/Inventory/StockManager.cs" + - path: "Inventory.Tests/Inventory.Tests.csproj" + source: "fixtures/well-tested/Inventory.Tests/Inventory.Tests.csproj" + - path: "Inventory.Tests/StockManagerTests.cs" + source: "fixtures/well-tested/Inventory.Tests/StockManagerTests.cs" + assertions: + - type: "output_matches" + pattern: "(strong|solid|good|well.tested|thorough|effective|high.*kill|most.*killed|most.*caught)" + - type: "exit_success" + rubric: + - "Recognized that the test suite has good mutation coverage — most mutations at boundaries and guards would be caught" + - "Acknowledged the boundary tests for NeedsReorder (below, at, and above threshold)" + - "Acknowledged that both zero and negative quantities are tested for guard clauses" + - "Acknowledged that RemoveStock verifies both the return value and the resulting stock level" + - "If any gaps were found, they were presented as minor improvements rather than critical weaknesses" + timeout: 300 + + # ========================================================================== + # Scenario 4: Non-activation — user wants to write new tests + # ========================================================================== + + - name: "Decline request to write new tests from scratch" + prompt: | + I need to write unit tests for my ShoppingCart class. It handles + adding items, removing items, calculating totals, and applying promo codes. + Can you write a complete MSTest test suite for me? + expect_activation: false + setup: + files: + - path: "ShoppingCart.cs" + content: | + namespace Commerce; + + public sealed class ShoppingCart + { + private readonly List<(string Sku, decimal Price, int Qty)> _items = new(); + + public void AddItem(string sku, decimal price, int qty) => + _items.Add((sku, price, qty)); + + public bool RemoveItem(string sku) => + _items.RemoveAll(i => i.Sku == sku) > 0; + + public decimal GetTotal() => + _items.Sum(i => i.Price * i.Qty); + } + assertions: + - type: "output_matches" + pattern: "(TestMethod|TestClass|\\[Fact\\]|test)" + rubric: + - "Wrote test methods for the ShoppingCart class" + - "Covered the AddItem and GetTotal methods" + timeout: 120 diff --git a/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/boundary-gaps/PricingEngine.Tests/DiscountCalculatorTests.cs b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/boundary-gaps/PricingEngine.Tests/DiscountCalculatorTests.cs new file mode 100644 index 0000000000..8e85825db8 --- /dev/null +++ b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/boundary-gaps/PricingEngine.Tests/DiscountCalculatorTests.cs @@ -0,0 +1,109 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PricingEngine; + +namespace PricingEngine.Tests; + +[TestClass] +public sealed class DiscountCalculatorTests +{ + // -- CalculateDiscount tests -- + // These tests exercise the middle of each tier but never test the exact boundaries. + // A mutation like >= 1000 → > 1000 would survive. + + [TestMethod] + public void CalculateDiscount_LargeOrder_Returns15Percent() + { + var calc = new DiscountCalculator(); + var discount = calc.CalculateDiscount(2000m); + Assert.AreEqual(300m, discount); + } + + [TestMethod] + public void CalculateDiscount_MediumOrder_Returns10Percent() + { + var calc = new DiscountCalculator(); + var discount = calc.CalculateDiscount(750m); + Assert.AreEqual(75m, discount); + } + + [TestMethod] + public void CalculateDiscount_SmallOrder_Returns5Percent() + { + var calc = new DiscountCalculator(); + var discount = calc.CalculateDiscount(200m); + Assert.AreEqual(10m, discount); + } + + [TestMethod] + public void CalculateDiscount_TinyOrder_ReturnsZero() + { + var calc = new DiscountCalculator(); + var discount = calc.CalculateDiscount(50m); + Assert.AreEqual(0m, discount); + } + + [TestMethod] + public void CalculateDiscount_NegativeAmount_Throws() + { + var calc = new DiscountCalculator(); + Assert.ThrowsException( + () => calc.CalculateDiscount(-1m)); + } + + // -- CalculateShipping tests -- + // Tests free shipping for a large order, but never tests the boundary at 250. + // Does not test heavy item fee boundary at 10kg. + // Does not test the express surcharge. + + [TestMethod] + public void CalculateShipping_LargeOrder_FreeShipping() + { + var calc = new DiscountCalculator(); + var cost = calc.CalculateShipping(500m, 2.0, false); + Assert.AreEqual(0m, cost); + } + + [TestMethod] + public void CalculateShipping_SmallOrder_ReturnsBaseCost() + { + var calc = new DiscountCalculator(); + var cost = calc.CalculateShipping(50m, 2.0, false); + Assert.AreEqual(6.00m, cost); + } + + [TestMethod] + public void CalculateShipping_ZeroWeight_Throws() + { + var calc = new DiscountCalculator(); + Assert.ThrowsException( + () => calc.CalculateShipping(50m, 0, false)); + } + + // -- ApplyCoupon tests -- + // Tests a percentage coupon but not the minimum floor (total can't go below 1.00). + // Does not test unknown coupon codes returning zero discount. + + [TestMethod] + public void ApplyCoupon_Save10_Applies10Percent() + { + var calc = new DiscountCalculator(); + var result = calc.ApplyCoupon(100m, "SAVE10"); + Assert.AreEqual(90m, result); + } + + [TestMethod] + public void ApplyCoupon_Flat50_SubtractsFifty() + { + var calc = new DiscountCalculator(); + var result = calc.ApplyCoupon(200m, "FLAT50"); + Assert.AreEqual(150m, result); + } + + [TestMethod] + public void ApplyCoupon_NullCoupon_Throws() + { + var calc = new DiscountCalculator(); + Assert.ThrowsException( + () => calc.ApplyCoupon(100m, null!)); + } +} diff --git a/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/boundary-gaps/PricingEngine.Tests/PricingEngine.Tests.csproj b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/boundary-gaps/PricingEngine.Tests/PricingEngine.Tests.csproj new file mode 100644 index 0000000000..bf173bd7a4 --- /dev/null +++ b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/boundary-gaps/PricingEngine.Tests/PricingEngine.Tests.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + + + + + + + + diff --git a/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/boundary-gaps/PricingEngine/DiscountCalculator.cs b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/boundary-gaps/PricingEngine/DiscountCalculator.cs new file mode 100644 index 0000000000..9b80a33ca9 --- /dev/null +++ b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/boundary-gaps/PricingEngine/DiscountCalculator.cs @@ -0,0 +1,72 @@ +namespace PricingEngine; + +public class DiscountCalculator +{ + /// + /// Applies a tiered discount based on order total. + /// Orders >= 1000 get 15%, >= 500 get 10%, >= 100 get 5%, below 100 get 0%. + /// + public decimal CalculateDiscount(decimal orderTotal) + { + if (orderTotal < 0) + throw new ArgumentOutOfRangeException(nameof(orderTotal), "Order total cannot be negative"); + + if (orderTotal >= 1000m) + return orderTotal * 0.15m; + if (orderTotal >= 500m) + return orderTotal * 0.10m; + if (orderTotal >= 100m) + return orderTotal * 0.05m; + + return 0m; + } + + /// + /// Calculates shipping cost. Free shipping for orders over 250. + /// Express adds 50% surcharge. Items over 10kg add a heavy item fee. + /// + public decimal CalculateShipping(decimal orderTotal, double weightKg, bool express) + { + if (orderTotal <= 0) + throw new ArgumentOutOfRangeException(nameof(orderTotal)); + if (weightKg <= 0) + throw new ArgumentOutOfRangeException(nameof(weightKg)); + + if (orderTotal > 250m) + return 0m; + + decimal baseCost = 5.00m + (decimal)(weightKg * 0.50); + + if (weightKg > 10.0) + baseCost += 15.00m; + + if (express) + baseCost *= 1.50m; + + return baseCost; + } + + /// + /// Applies a coupon code. Returns the adjusted total after coupon. + /// Coupons cannot reduce the total below 1.00. + /// + public decimal ApplyCoupon(decimal total, string couponCode) + { + ArgumentNullException.ThrowIfNull(couponCode); + + if (total <= 0) + throw new ArgumentOutOfRangeException(nameof(total)); + + decimal discount = couponCode.ToUpperInvariant() switch + { + "SAVE10" => total * 0.10m, + "SAVE20" => total * 0.20m, + "FLAT5" => 5.00m, + "FLAT50" => 50.00m, + _ => 0m + }; + + decimal result = total - discount; + return result < 1.00m ? 1.00m : result; + } +} diff --git a/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/boundary-gaps/PricingEngine/PricingEngine.csproj b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/boundary-gaps/PricingEngine/PricingEngine.csproj new file mode 100644 index 0000000000..0957a12e21 --- /dev/null +++ b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/boundary-gaps/PricingEngine/PricingEngine.csproj @@ -0,0 +1,6 @@ + + + net10.0 + enable + + diff --git a/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/logic-gaps/AccessControl.Tests/AccessCheckerTests.cs b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/logic-gaps/AccessControl.Tests/AccessCheckerTests.cs new file mode 100644 index 0000000000..0502ee32bf --- /dev/null +++ b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/logic-gaps/AccessControl.Tests/AccessCheckerTests.cs @@ -0,0 +1,96 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using AccessControl; + +namespace AccessControl.Tests; + +[TestClass] +public sealed class AccessCheckerTests +{ + // -- GetPermission tests -- + // Tests the happy path for Admin and User, but never checks Editor on system resources, + // never verifies Guest access denial, and never checks CanWrite for User role. + // A flip of CanRead/CanWrite for Editor+system would survive. + // Removing the Guest denial branch would survive. + + [TestMethod] + public void GetPermission_Admin_CanReadAndWrite() + { + var checker = new AccessChecker(); + var perm = checker.GetPermission(Role.Admin, "documents/report.pdf"); + Assert.IsTrue(perm.CanRead); + Assert.IsTrue(perm.CanWrite); + } + + [TestMethod] + public void GetPermission_Admin_SystemResource_CanReadAndWrite() + { + var checker = new AccessChecker(); + var perm = checker.GetPermission(Role.Admin, "sys/config"); + Assert.IsTrue(perm.CanRead); + Assert.IsTrue(perm.CanWrite); + } + + [TestMethod] + public void GetPermission_Editor_NormalResource_CanReadAndWrite() + { + var checker = new AccessChecker(); + var perm = checker.GetPermission(Role.Editor, "documents/report.pdf"); + Assert.IsTrue(perm.CanRead); + Assert.IsTrue(perm.CanWrite); + } + + [TestMethod] + public void GetPermission_User_NormalResource_CanRead() + { + var checker = new AccessChecker(); + var perm = checker.GetPermission(Role.User, "documents/report.pdf"); + Assert.IsTrue(perm.CanRead); + } + + [TestMethod] + public void GetPermission_NullResource_Throws() + { + var checker = new AccessChecker(); + Assert.ThrowsException( + () => checker.GetPermission(Role.Admin, null!)); + } + + // -- CanPerform tests -- + // Only tests the read path, never tests writeAccess=true. + // A mutation flipping the ternary (writeAccess ? CanWrite : CanRead) would survive. + + [TestMethod] + public void CanPerform_AdminRead_ReturnsTrue() + { + var checker = new AccessChecker(); + Assert.IsTrue(checker.CanPerform(Role.Admin, "documents/report.pdf", false)); + } + + [TestMethod] + public void CanPerform_UserReadNormal_ReturnsTrue() + { + var checker = new AccessChecker(); + Assert.IsTrue(checker.CanPerform(Role.User, "documents/report.pdf", false)); + } + + // -- ElevateRole tests -- + // Tests the happy path but never checks null token, empty token, or invalid token. + // Removing the null/empty check would survive. + // Also never verifies that a Guest can't be elevated to Editor. + + [TestMethod] + public void ElevateRole_EditorToAdmin_WithValidToken() + { + var checker = new AccessChecker(); + var result = checker.ElevateRole(Role.Editor, "ELEVATE-ADMIN"); + Assert.AreEqual(Role.Admin, result); + } + + [TestMethod] + public void ElevateRole_UserToEditor_WithValidToken() + { + var checker = new AccessChecker(); + var result = checker.ElevateRole(Role.User, "ELEVATE-EDITOR"); + Assert.AreEqual(Role.Editor, result); + } +} diff --git a/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/logic-gaps/AccessControl.Tests/AccessControl.Tests.csproj b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/logic-gaps/AccessControl.Tests/AccessControl.Tests.csproj new file mode 100644 index 0000000000..f308d86eb2 --- /dev/null +++ b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/logic-gaps/AccessControl.Tests/AccessControl.Tests.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + + + + + + + + diff --git a/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/logic-gaps/AccessControl/AccessChecker.cs b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/logic-gaps/AccessControl/AccessChecker.cs new file mode 100644 index 0000000000..1f187a1f82 --- /dev/null +++ b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/logic-gaps/AccessControl/AccessChecker.cs @@ -0,0 +1,61 @@ +namespace AccessControl; + +public enum Role { Guest, User, Editor, Admin } + +public class Permission +{ + public string Resource { get; init; } = ""; + public bool CanRead { get; init; } + public bool CanWrite { get; init; } +} + +public class AccessChecker +{ + /// + /// Checks if a user with the given role can access a resource. + /// Admins can access everything. Editors can read and write non-system resources. + /// Users can only read non-system resources. Guests have no access. + /// + public Permission GetPermission(Role role, string resource) + { + ArgumentNullException.ThrowIfNull(resource); + + bool isSystem = resource.StartsWith("sys/", StringComparison.OrdinalIgnoreCase); + + return role switch + { + Role.Admin => new Permission { Resource = resource, CanRead = true, CanWrite = true }, + Role.Editor when !isSystem => new Permission { Resource = resource, CanRead = true, CanWrite = true }, + Role.Editor => new Permission { Resource = resource, CanRead = true, CanWrite = false }, + Role.User when !isSystem => new Permission { Resource = resource, CanRead = true, CanWrite = false }, + _ => new Permission { Resource = resource, CanRead = false, CanWrite = false }, + }; + } + + /// + /// Returns true if the user can perform the requested action. + /// + public bool CanPerform(Role role, string resource, bool writeAccess) + { + var perm = GetPermission(role, resource); + return writeAccess ? perm.CanWrite : perm.CanRead; + } + + /// + /// Elevates a user's effective role if they have a temporary elevation token. + /// Returns the elevated role, or the original role if the token is invalid. + /// + public Role ElevateRole(Role currentRole, string? token) + { + if (string.IsNullOrEmpty(token)) + return currentRole; + + if (token == "ELEVATE-ADMIN" && currentRole >= Role.Editor) + return Role.Admin; + + if (token == "ELEVATE-EDITOR" && currentRole >= Role.User) + return Role.Editor; + + return currentRole; + } +} diff --git a/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/logic-gaps/AccessControl/AccessControl.csproj b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/logic-gaps/AccessControl/AccessControl.csproj new file mode 100644 index 0000000000..0957a12e21 --- /dev/null +++ b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/logic-gaps/AccessControl/AccessControl.csproj @@ -0,0 +1,6 @@ + + + net10.0 + enable + + diff --git a/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/well-tested/Inventory.Tests/Inventory.Tests.csproj b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/well-tested/Inventory.Tests/Inventory.Tests.csproj new file mode 100644 index 0000000000..4f6fc2b867 --- /dev/null +++ b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/well-tested/Inventory.Tests/Inventory.Tests.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + + + + + + + + diff --git a/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/well-tested/Inventory.Tests/StockManagerTests.cs b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/well-tested/Inventory.Tests/StockManagerTests.cs new file mode 100644 index 0000000000..a6bb0b2d19 --- /dev/null +++ b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/well-tested/Inventory.Tests/StockManagerTests.cs @@ -0,0 +1,138 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Inventory; + +namespace Inventory.Tests; + +[TestClass] +public sealed class StockManagerTests +{ + // -- AddStock -- + + [TestMethod] + public void AddStock_NewSku_SetsLevel() + { + var mgr = new StockManager(); + mgr.AddStock("SKU-A", 10); + Assert.AreEqual(10, mgr.GetStockLevel("SKU-A")); + } + + [TestMethod] + public void AddStock_ExistingSku_IncrementsLevel() + { + var mgr = new StockManager(); + mgr.AddStock("SKU-A", 10); + mgr.AddStock("SKU-A", 5); + Assert.AreEqual(15, mgr.GetStockLevel("SKU-A")); + } + + [TestMethod] + public void AddStock_ZeroQuantity_Throws() + { + var mgr = new StockManager(); + Assert.ThrowsException( + () => mgr.AddStock("SKU-A", 0)); + } + + [TestMethod] + public void AddStock_NegativeQuantity_Throws() + { + var mgr = new StockManager(); + Assert.ThrowsException( + () => mgr.AddStock("SKU-A", -1)); + } + + [TestMethod] + public void AddStock_NullSku_Throws() + { + var mgr = new StockManager(); + Assert.ThrowsException( + () => mgr.AddStock(null!, 5)); + } + + // -- RemoveStock -- + + [TestMethod] + public void RemoveStock_SufficientStock_ReturnsTrue() + { + var mgr = new StockManager(); + mgr.AddStock("SKU-A", 10); + Assert.IsTrue(mgr.RemoveStock("SKU-A", 5)); + Assert.AreEqual(5, mgr.GetStockLevel("SKU-A")); + } + + [TestMethod] + public void RemoveStock_ExactAmount_RemovesSku() + { + var mgr = new StockManager(); + mgr.AddStock("SKU-A", 10); + Assert.IsTrue(mgr.RemoveStock("SKU-A", 10)); + Assert.AreEqual(0, mgr.GetStockLevel("SKU-A")); + } + + [TestMethod] + public void RemoveStock_InsufficientStock_ReturnsFalse() + { + var mgr = new StockManager(); + mgr.AddStock("SKU-A", 5); + Assert.IsFalse(mgr.RemoveStock("SKU-A", 10)); + Assert.AreEqual(5, mgr.GetStockLevel("SKU-A")); + } + + [TestMethod] + public void RemoveStock_UnknownSku_ReturnsFalse() + { + var mgr = new StockManager(); + Assert.IsFalse(mgr.RemoveStock("NONEXISTENT", 1)); + } + + [TestMethod] + public void RemoveStock_ZeroQuantity_Throws() + { + var mgr = new StockManager(); + Assert.ThrowsException( + () => mgr.RemoveStock("SKU-A", 0)); + } + + // -- GetStockLevel -- + + [TestMethod] + public void GetStockLevel_UnknownSku_ReturnsZero() + { + var mgr = new StockManager(); + Assert.AreEqual(0, mgr.GetStockLevel("NONEXISTENT")); + } + + // -- NeedsReorder -- + + [TestMethod] + public void NeedsReorder_BelowThreshold_ReturnsTrue() + { + var mgr = new StockManager(); + mgr.AddStock("SKU-A", 3); + Assert.IsTrue(mgr.NeedsReorder("SKU-A", 5)); + } + + [TestMethod] + public void NeedsReorder_AtThreshold_ReturnsFalse() + { + var mgr = new StockManager(); + mgr.AddStock("SKU-A", 5); + Assert.IsFalse(mgr.NeedsReorder("SKU-A", 5)); + } + + [TestMethod] + public void NeedsReorder_AboveThreshold_ReturnsFalse() + { + var mgr = new StockManager(); + mgr.AddStock("SKU-A", 10); + Assert.IsFalse(mgr.NeedsReorder("SKU-A", 5)); + } + + [TestMethod] + public void NeedsReorder_NegativeThreshold_Throws() + { + var mgr = new StockManager(); + Assert.ThrowsException( + () => mgr.NeedsReorder("SKU-A", -1)); + } +} diff --git a/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/well-tested/Inventory/Inventory.csproj b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/well-tested/Inventory/Inventory.csproj new file mode 100644 index 0000000000..0957a12e21 --- /dev/null +++ b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/well-tested/Inventory/Inventory.csproj @@ -0,0 +1,6 @@ + + + net10.0 + enable + + diff --git a/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/well-tested/Inventory/StockManager.cs b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/well-tested/Inventory/StockManager.cs new file mode 100644 index 0000000000..91c285e3f5 --- /dev/null +++ b/tests/dotnet-experimental/exp-test-gap-analysis/fixtures/well-tested/Inventory/StockManager.cs @@ -0,0 +1,49 @@ +namespace Inventory; + +public class StockManager +{ + private readonly Dictionary _stock = new(); + + public void AddStock(string sku, int quantity) + { + ArgumentNullException.ThrowIfNull(sku); + if (quantity <= 0) + throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive"); + + if (_stock.ContainsKey(sku)) + _stock[sku] += quantity; + else + _stock[sku] = quantity; + } + + public bool RemoveStock(string sku, int quantity) + { + ArgumentNullException.ThrowIfNull(sku); + if (quantity <= 0) + throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive"); + + if (!_stock.TryGetValue(sku, out int current) || current < quantity) + return false; + + _stock[sku] = current - quantity; + if (_stock[sku] == 0) + _stock.Remove(sku); + + return true; + } + + public int GetStockLevel(string sku) + { + ArgumentNullException.ThrowIfNull(sku); + return _stock.TryGetValue(sku, out int level) ? level : 0; + } + + public bool NeedsReorder(string sku, int threshold) + { + ArgumentNullException.ThrowIfNull(sku); + if (threshold < 0) + throw new ArgumentOutOfRangeException(nameof(threshold)); + + return GetStockLevel(sku) < threshold; + } +}