mirror of
https://github.com/dotnet/skills.git
synced 2026-09-20 09:49:54 +08:00
Add eval tests for csharp-refactoring and dotnet-breaking-changes skills
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,255 +1,180 @@
|
||||
name: csharp-refactoring
|
||||
description: Evaluates the dotnet/csharp-refactoring skill
|
||||
type: capability
|
||||
defaults:
|
||||
timeout: 15m
|
||||
stimuli:
|
||||
- name: Rename a shipped public method without breaking existing callers
|
||||
prompt: "In the Billing class library, rename the shipped public method `AppSettingsHelper.ParseIntSetting` to `ParseIntegerSetting` and migrate this solution's callers, while preserving compatibility for existing callers of the old public API. `PublicAPI.Shipped.txt` records the shipped surface. The solution is at Fixture.sln."
|
||||
environment: &fixture-environment
|
||||
files:
|
||||
- src: Fixture.sln
|
||||
dest: Fixture.sln
|
||||
- src: src/Billing/AppSettingsHelper.cs
|
||||
dest: src/Billing/AppSettingsHelper.cs
|
||||
- src: src/Billing/Billing.csproj
|
||||
dest: src/Billing/Billing.csproj
|
||||
- src: src/Billing/Coupons.cs
|
||||
dest: src/Billing/Coupons.cs
|
||||
- src: src/Billing/Coupons.g.cs.template
|
||||
dest: src/Billing/Coupons.g.cs.template
|
||||
- src: src/Billing/OrderProcessor.cs
|
||||
dest: src/Billing/OrderProcessor.cs
|
||||
- src: src/Billing/PlatformInfo.cs
|
||||
dest: src/Billing/PlatformInfo.cs
|
||||
- src: src/Billing/Pricing.cs
|
||||
dest: src/Billing/Pricing.cs
|
||||
- src: src/Billing/PublicAPI.Shipped.txt
|
||||
dest: src/Billing/PublicAPI.Shipped.txt
|
||||
- src: tests/Billing.Tests/Billing.Tests.csproj
|
||||
dest: tests/Billing.Tests/Billing.Tests.csproj
|
||||
- src: tests/Billing.Tests/BillingTests.cs
|
||||
dest: tests/Billing.Tests/BillingTests.cs
|
||||
graders:
|
||||
- type: file-contains
|
||||
config:
|
||||
path: src/Billing/AppSettingsHelper.cs
|
||||
value: ParseIntegerSetting
|
||||
- type: file-contains
|
||||
config:
|
||||
path: tests/Billing.Tests/BillingTests.cs
|
||||
value: ParseIntegerSetting
|
||||
- type: file-contains
|
||||
config:
|
||||
path: src/Billing/AppSettingsHelper.cs
|
||||
value: Obsolete
|
||||
- type: file-contains
|
||||
config:
|
||||
path: src/Billing/AppSettingsHelper.cs
|
||||
value: ParseIntSetting
|
||||
- type: run-command
|
||||
config:
|
||||
command: dotnet test Fixture.sln --verbosity normal
|
||||
expected_exit_code: 0
|
||||
stdout_contains: "Total tests: 10"
|
||||
timeout: 5m
|
||||
- type: output-matches
|
||||
config:
|
||||
pattern: (public API|compatib|obsolete|shim)
|
||||
- type: output-matches
|
||||
config:
|
||||
pattern: (dotnet (build|test)|rebuild|re-?run[\s\S]{0,30}tests|tests?[\s\S]{0,30}(pass|green))
|
||||
- type: prompt
|
||||
scenarios:
|
||||
- name: "Rename a method across its declaration and every caller"
|
||||
prompt: "In the Billing class library, the method `OrderProcessor.DoStuff` is badly named for what it does — it computes an invoice. Rename it to `ComputeInvoice` everywhere it is declared and called, without changing any behavior. The solution is at Fixture.sln."
|
||||
setup:
|
||||
copy_test_files: true
|
||||
assertions:
|
||||
- type: "file_contains"
|
||||
path: "src/Billing/OrderProcessor.cs"
|
||||
value: "ComputeInvoice"
|
||||
# The rename must propagate to every caller. BillingTests.cs calls the method
|
||||
# directly, so a correct rename lands the new name here too (and the old call
|
||||
# would otherwise fail to compile).
|
||||
- type: "file_contains"
|
||||
path: "tests/Billing.Tests/BillingTests.cs"
|
||||
value: "ComputeInvoice"
|
||||
# Behavior-preservation + full-propagation gate: if any declaration or caller
|
||||
# was missed, the test project fails to compile and this assertion fails.
|
||||
- type: run_command_and_assert
|
||||
command_to_run: "dotnet"
|
||||
command_arguments: "test Fixture.sln -v:q"
|
||||
expected_exit_code: 0
|
||||
expected_std_output_contains: "Passed!"
|
||||
command_timeout: 300
|
||||
- type: "output_matches"
|
||||
pattern: "(dotnet (build|test)|rebuild|re-?run.{0,20}tests|tests? .{0,20}(pass|green))"
|
||||
rubric:
|
||||
- Migrates in-repository callers to ParseIntegerSetting while preserving the shipped ParseIntSetting entry point as an obsolete forwarding compatibility shim
|
||||
- Treats PublicAPI.Shipped.txt as evidence that deleting the old method would break a public contract, rather than blindly removing every old-name occurrence
|
||||
- Rebuilds and re-runs the existing tests after the rename to confirm behavior is preserved
|
||||
- "Establishes a green build and test baseline before renaming"
|
||||
- "Renames by tracking true binding references rather than a blind text find-and-replace that would also hit comments, strings, or unrelated test method names"
|
||||
- "Rebuilds and re-runs the existing tests after the rename to confirm behavior is preserved"
|
||||
- "Keeps this a single, focused operation without bundling unrelated edits"
|
||||
timeout: 600
|
||||
|
||||
- name: Rename a member declared by a generated partial source
|
||||
prompt: "Rename the private `Coupons.RateFor` helper to `DiscountRateFor` everywhere it is declared and called, preserving behavior. The solution is at Fixture.sln."
|
||||
environment: *fixture-environment
|
||||
graders:
|
||||
- type: file-contains
|
||||
config:
|
||||
path: src/Billing/Coupons.g.cs.template
|
||||
value: DiscountRateFor
|
||||
- type: file-contains
|
||||
config:
|
||||
path: src/Billing/Coupons.cs
|
||||
value: DiscountRateFor
|
||||
- type: file-not-contains
|
||||
config:
|
||||
path: src/Billing/Coupons.cs
|
||||
value: amount * RateFor(code)
|
||||
- type: run-command
|
||||
config:
|
||||
command: dotnet test Fixture.sln --verbosity normal
|
||||
expected_exit_code: 0
|
||||
stdout_contains: "Total tests: 10"
|
||||
timeout: 5m
|
||||
- type: output-matches
|
||||
config:
|
||||
pattern: (generated|generator|template)
|
||||
- type: output-matches
|
||||
config:
|
||||
pattern: (dotnet (build|test)|rebuild|re-?run[\s\S]{0,30}tests|tests?[\s\S]{0,30}(pass|green))
|
||||
- type: prompt
|
||||
- name: "Extract a repeated calculation into a private helper"
|
||||
prompt: "`OrderProcessor.DoStuff` is too long. Extract the discount-then-tax calculation into a private helper method and call it, without changing what the code computes. The solution is at Fixture.sln."
|
||||
setup:
|
||||
copy_test_files: true
|
||||
assertions:
|
||||
# Behavior-preservation gate: the extracted helper must compute identical
|
||||
# results, so the existing tests must still pass.
|
||||
- type: run_command_and_assert
|
||||
command_to_run: "dotnet"
|
||||
command_arguments: "test Fixture.sln -v:q"
|
||||
expected_exit_code: 0
|
||||
expected_std_output_contains: "Passed!"
|
||||
command_timeout: 300
|
||||
# A private helper method now exists (OrderProcessor had none before).
|
||||
- type: "file_contains"
|
||||
path: "src/Billing/OrderProcessor.cs"
|
||||
value: "private"
|
||||
- type: "output_matches"
|
||||
pattern: "(dotnet (build|test)|rebuild|re-?run.{0,20}tests|tests? .{0,20}(pass|green))"
|
||||
rubric:
|
||||
- Finds the generated declaration and the hand-authored partial caller as binding-related parts of one rename
|
||||
- Edits Coupons.g.cs.template, the build's source of truth, rather than a generated file under obj
|
||||
- Rebuilds and re-runs the tests so generation occurs again and proves the renamed declaration and caller agree
|
||||
- "Establishes a green baseline before editing"
|
||||
- "Extracts the block into a new method preserving the exact same computation and results"
|
||||
- "Verifies behavior is preserved by building and running the existing tests after extracting"
|
||||
- "Does not slip a bug fix or behavior change into the extraction"
|
||||
timeout: 600
|
||||
|
||||
- name: Consolidate duplicate public helpers without breaking shipped callers
|
||||
prompt: "`ConfigReader` duplicates the parsing implementation in `AppSettingsHelper`. Consolidate the implementation so there is one source of truth, but preserve the shipped `ConfigReader.ReadInt` and `ReadBool` APIs for existing callers. Migrate this solution's callers where appropriate and verify Fixture.sln."
|
||||
environment: *fixture-environment
|
||||
graders:
|
||||
- type: file-contains
|
||||
config:
|
||||
path: src/Billing/AppSettingsHelper.cs
|
||||
value: AppSettingsHelper.ParseIntSetting
|
||||
- type: file-contains
|
||||
config:
|
||||
path: src/Billing/AppSettingsHelper.cs
|
||||
value: AppSettingsHelper.ParseBoolSetting
|
||||
- type: file-contains
|
||||
config:
|
||||
path: src/Billing/AppSettingsHelper.cs
|
||||
value: class ConfigReader
|
||||
- type: file-contains
|
||||
config:
|
||||
path: src/Billing/PublicAPI.Shipped.txt
|
||||
value: Billing.ConfigReader
|
||||
- type: run-command
|
||||
config:
|
||||
command: dotnet test Fixture.sln --verbosity normal
|
||||
expected_exit_code: 0
|
||||
stdout_contains: "Total tests: 10"
|
||||
timeout: 5m
|
||||
- type: output-matches
|
||||
config:
|
||||
pattern: (public API|compatib|shipped|forward)
|
||||
- type: output-matches
|
||||
config:
|
||||
pattern: (dotnet (build|test)|rebuild|re-?run.{0,20}tests|tests? .{0,20}(pass|green))
|
||||
- type: prompt
|
||||
- name: "Consolidate a duplicated block into a single shared helper"
|
||||
prompt: "Inside `OrderProcessor.DoStuff` the discount-plus-tax calculation is duplicated (it appears twice, once for the order total and once for the quote). Consolidate the duplicated logic into a single shared helper used by both, without changing behavior. The solution is at Fixture.sln."
|
||||
setup:
|
||||
copy_test_files: true
|
||||
assertions:
|
||||
# Behavior-preservation gate: the single shared helper must be equivalent to
|
||||
# both original copies, so the existing tests must still pass.
|
||||
- type: run_command_and_assert
|
||||
command_to_run: "dotnet"
|
||||
command_arguments: "test Fixture.sln -v:q"
|
||||
expected_exit_code: 0
|
||||
expected_std_output_contains: "Passed!"
|
||||
command_timeout: 300
|
||||
# A shared private helper now exists (OrderProcessor had none before).
|
||||
- type: "file_contains"
|
||||
path: "src/Billing/OrderProcessor.cs"
|
||||
value: "private"
|
||||
- type: "output_matches"
|
||||
pattern: "(dotnet (build|test)|rebuild|re-?run.{0,20}tests|tests? .{0,20}(pass|green))"
|
||||
rubric:
|
||||
- Removes the duplicate parsing implementation by making ConfigReader delegate to AppSettingsHelper
|
||||
- Preserves the shipped ConfigReader type and methods rather than deleting a public contract during de-duplication
|
||||
- Re-runs the build and tests after consolidating to prove behavior and existing callers remain intact
|
||||
- "Establishes a green baseline before editing"
|
||||
- "Factors the duplicated block into one shared helper that both call sites use"
|
||||
- "Confirms the consolidated helper is semantically equivalent to each original copy"
|
||||
- "Re-runs the build and tests after consolidating to prove behavior is unchanged"
|
||||
timeout: 600
|
||||
|
||||
- name: Inline a pass-through wrapper and update callers
|
||||
- name: "Inline a pass-through wrapper and update callers"
|
||||
prompt: "`LegacyTax.ApplyTaxWrapper` does nothing but forward to `TaxRules.Apply`. Inline it: update every caller to call `TaxRules.Apply` directly and remove the wrapper, without changing behavior. The solution is at Fixture.sln."
|
||||
environment: *fixture-environment
|
||||
graders:
|
||||
setup:
|
||||
copy_test_files: true
|
||||
assertions:
|
||||
# Behavior-preservation + full-propagation gate: every caller (including the
|
||||
# test that references the wrapper) must be updated to the underlying call,
|
||||
# or the test project fails to compile.
|
||||
- type: run-command
|
||||
config:
|
||||
command: dotnet test Fixture.sln --verbosity normal
|
||||
expected_exit_code: 0
|
||||
stdout_contains: "Total tests: 10"
|
||||
timeout: 5m
|
||||
- type: run_command_and_assert
|
||||
command_to_run: "dotnet"
|
||||
command_arguments: "test Fixture.sln -v:q"
|
||||
expected_exit_code: 0
|
||||
expected_std_output_contains: "Passed!"
|
||||
command_timeout: 300
|
||||
# The pass-through wrapper is actually gone.
|
||||
- type: file-not-contains
|
||||
config:
|
||||
path: src/Billing/Pricing.cs
|
||||
value: ApplyTaxWrapper
|
||||
- type: output-matches
|
||||
config:
|
||||
pattern: (dotnet (build|test)|rebuild|re-?run.{0,20}tests|tests? .{0,20}(pass|green))
|
||||
- type: prompt
|
||||
- type: "file_not_contains"
|
||||
path: "src/Billing/Pricing.cs"
|
||||
value: "ApplyTaxWrapper"
|
||||
- type: "output_matches"
|
||||
pattern: "(dotnet (build|test)|rebuild|re-?run.{0,20}tests|tests? .{0,20}(pass|green))"
|
||||
rubric:
|
||||
- Finds the true binding references to the wrapper before removing it, not just textual matches
|
||||
- Updates every caller to the underlying call and removes the wrapper in one focused operation
|
||||
- Lets the compiler catch any missed reference, then rebuilds and re-runs the tests to confirm green
|
||||
- "Finds the true binding references to the wrapper before removing it, not just textual matches"
|
||||
- "Updates every caller to the underlying call and removes the wrapper in one focused operation"
|
||||
- "Lets the compiler catch any missed reference, then rebuilds and re-runs the tests to confirm green"
|
||||
timeout: 600
|
||||
|
||||
- name: Merge two near-identical types into one parameterized type
|
||||
- name: "Merge two near-identical types into one parameterized type"
|
||||
prompt: "`GoldPricing` and `SilverPricing` are near-identical. Consolidate them into a single parameterized pricing type and update any usages, without changing behavior. The solution is at Fixture.sln."
|
||||
environment: *fixture-environment
|
||||
graders:
|
||||
setup:
|
||||
copy_test_files: true
|
||||
assertions:
|
||||
# Behavior-preservation + full-propagation gate: usages (including the test
|
||||
# that constructs GoldPricing/SilverPricing) must be updated to the merged
|
||||
# type, or the test project fails to compile.
|
||||
- type: run-command
|
||||
config:
|
||||
command: dotnet test Fixture.sln --verbosity normal
|
||||
expected_exit_code: 0
|
||||
stdout_contains: "Total tests: 10"
|
||||
timeout: 5m
|
||||
- type: run_command_and_assert
|
||||
command_to_run: "dotnet"
|
||||
command_arguments: "test Fixture.sln -v:q"
|
||||
expected_exit_code: 0
|
||||
expected_std_output_contains: "Passed!"
|
||||
command_timeout: 300
|
||||
# The two near-identical classes are actually gone (merged into one type).
|
||||
- type: file-not-contains
|
||||
config:
|
||||
path: src/Billing/Pricing.cs
|
||||
value: class GoldPricing
|
||||
- type: file-not-contains
|
||||
config:
|
||||
path: src/Billing/Pricing.cs
|
||||
value: class SilverPricing
|
||||
- type: output-matches
|
||||
config:
|
||||
pattern: (dotnet (build|test)|rebuild|re-?run.{0,20}tests|tests? .{0,20}(pass|green))
|
||||
- type: prompt
|
||||
- type: "file_not_contains"
|
||||
path: "src/Billing/Pricing.cs"
|
||||
value: "class GoldPricing"
|
||||
- type: "file_not_contains"
|
||||
path: "src/Billing/Pricing.cs"
|
||||
value: "class SilverPricing"
|
||||
- type: "output_matches"
|
||||
pattern: "(dotnet (build|test)|rebuild|re-?run.{0,20}tests|tests? .{0,20}(pass|green))"
|
||||
rubric:
|
||||
- Merges the two classes into a single type parameterized by name and rate, preserving each tier's values
|
||||
- Updates all usages and re-runs the build and tests to prove behavior is unchanged
|
||||
- "Establishes a green baseline before editing"
|
||||
- "Merges the two classes into a single type parameterized by name and rate, preserving each tier's values"
|
||||
- "Updates all usages and re-runs the build and tests to prove behavior is unchanged"
|
||||
timeout: 600
|
||||
|
||||
- name: Decline a framework and package upgrade dressed up as a refactor
|
||||
prompt: Upgrade the Billing project in Fixture.sln to a newer target framework and bump all its NuGet packages to their latest major versions, treating this as a behavior-preserving refactor.
|
||||
environment: *fixture-environment
|
||||
graders:
|
||||
- type: output-matches
|
||||
config:
|
||||
pattern: (not a .{0,20}refactor|behavior[- ]?(preserving|chang)|upgrade|major version|out of scope)
|
||||
- type: prompt
|
||||
- name: "Decline a framework and package upgrade dressed up as a refactor"
|
||||
prompt: "Upgrade the Billing project to a newer target framework and bump all its NuGet packages to their latest major versions. The solution is at Fixture.sln."
|
||||
expect_activation: false
|
||||
setup:
|
||||
copy_test_files: true
|
||||
assertions:
|
||||
- type: "output_matches"
|
||||
pattern: "(not a .{0,20}refactor|behavior[- ]?(preserving|chang)|upgrade|major version|out of scope)"
|
||||
rubric:
|
||||
- Recognizes that a framework or package major-version upgrade is not a behavior-preserving refactor
|
||||
- Declines or redirects to the appropriate upgrade workflow instead of treating it as a refactor
|
||||
- Does not silently perform a behavior-changing version bump under the guise of refactoring
|
||||
- "Recognizes that a framework or package major-version upgrade is not a behavior-preserving refactor"
|
||||
- "Declines or redirects to the appropriate upgrade workflow instead of treating it as a refactor"
|
||||
- "Does not silently perform a behavior-changing version bump under the guise of refactoring"
|
||||
timeout: 300
|
||||
|
||||
- name: Decline a new-feature request dressed up as a refactor
|
||||
prompt: 'In Fixture.sln, add a new "platinum" loyalty tier at 15% off and wire it into the pricing tiers so orders can use it, treating this as a behavior-preserving refactor.'
|
||||
environment: *fixture-environment
|
||||
graders:
|
||||
- type: output-matches
|
||||
config:
|
||||
pattern: (new feature|feature addition|not a .{0,20}refactor|behavior[- ]?(preserving|chang)|adds? new behavior)
|
||||
- type: prompt
|
||||
- name: "Decline a new-feature request dressed up as a refactor"
|
||||
prompt: "Add a new \"platinum\" loyalty tier at 15% off and wire it into the pricing tiers so orders can use it. The solution is at Fixture.sln."
|
||||
expect_activation: false
|
||||
setup:
|
||||
copy_test_files: true
|
||||
assertions:
|
||||
- type: "output_matches"
|
||||
pattern: "(new feature|feature addition|not a .{0,20}refactor|behavior[- ]?(preserving|chang)|adds? new behavior)"
|
||||
rubric:
|
||||
- Recognizes that adding a new pricing tier is a new feature, not a behavior-preserving refactor
|
||||
- Distinguishes the feature request from a refactor rather than conflating them, and does not claim behavior is preserved when it is deliberately being extended
|
||||
- If it proceeds, does so as an explicit feature addition with its own new tests, not under the guise of a no-behavior-change refactor
|
||||
- "Recognizes that adding a new pricing tier is a new feature, not a behavior-preserving refactor"
|
||||
- "Distinguishes the feature request from a refactor rather than conflating them, and does not claim behavior is preserved when it is deliberately being extended"
|
||||
- "If it proceeds, does so as an explicit feature addition with its own new tests, not under the guise of a no-behavior-change refactor"
|
||||
timeout: 300
|
||||
|
||||
- name: Keep behavior-changing bug fixes out of a behavior-preserving refactor
|
||||
prompt: "In Fixture.sln, rename `OrderProcessor.DoStuff` to `ComputeInvoice` and, while you are there, change the free-shipping threshold from $100 to $50. Treat the whole change as one behavior-preserving refactor."
|
||||
environment: *fixture-environment
|
||||
graders:
|
||||
- type: file-contains
|
||||
config:
|
||||
path: src/Billing/OrderProcessor.cs
|
||||
value: ComputeInvoice
|
||||
- type: file-contains
|
||||
config:
|
||||
path: tests/Billing.Tests/BillingTests.cs
|
||||
value: ComputeInvoice
|
||||
- type: file-contains
|
||||
config:
|
||||
path: src/Billing/OrderProcessor.cs
|
||||
value: subtotal >= 100m
|
||||
- type: output-matches
|
||||
config:
|
||||
pattern: (separate|follow[- ]?up|behavior[- ]?(preserving|chang)|not (a )?refactor)
|
||||
- type: output-matches
|
||||
config:
|
||||
pattern: (shipping|threshold|\$50)
|
||||
- type: run-command
|
||||
config:
|
||||
command: dotnet test Fixture.sln --verbosity normal
|
||||
expected_exit_code: 0
|
||||
stdout_contains: "Total tests: 10"
|
||||
timeout: 5m
|
||||
- type: prompt
|
||||
- name: "Keep behavior-changing bug fixes out of a behavior-preserving refactor"
|
||||
prompt: "Fix the known bugs first and keep any refactors in a separate follow-up PR. Do not mix behavior-changing bug fixes with refactoring under a behavior-preserving contract. The solution is at Fixture.sln."
|
||||
expect_activation: false
|
||||
setup:
|
||||
copy_test_files: true
|
||||
assertions:
|
||||
- type: "output_matches"
|
||||
pattern: "(separate|follow[- ]?up|bug fix|behavior[- ]?(preserving|chang)|not (a )?refactor)"
|
||||
rubric:
|
||||
- Completes the requested rename as a focused behavior-preserving refactor and migrates its callers
|
||||
- Refuses or defers the $50 shipping-threshold change because it deliberately changes invoice behavior and belongs in a separate tested change
|
||||
- Keeps the existing $100 threshold and does not claim the combined request is behavior-preserving
|
||||
- "Recognizes that a behavior-changing bug fix is distinct from a behavior-preserving refactor"
|
||||
- "Keeps the bug fix separate from any refactoring rather than bundling them under one behavior-preserving contract"
|
||||
- "Does not claim behavior is preserved for a change that deliberately alters behavior"
|
||||
timeout: 300
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>true</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Billing", "src\Billing\Billing.csproj", "{11111111-1111-1111-1111-111111111111}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Billing.Tests", "tests\Billing.Tests\Billing.Tests.csproj", "{22222222-2222-2222-2222-222222222222}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{11111111-1111-1111-1111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{11111111-1111-1111-1111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{11111111-1111-1111-1111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{11111111-1111-1111-1111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{22222222-2222-2222-2222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{22222222-2222-2222-2222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{22222222-2222-2222-2222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{22222222-2222-2222-2222-222222222222}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,165 @@
|
||||
scenarios:
|
||||
- name: "Answer a public-API question before consolidating duplicated parsing"
|
||||
prompt: "Is `AppSettingsHelper` part of the public API anywhere? Its int/bool setting parsing looks duplicated with `ConfigReader`. If it's safe to do, consolidate the setting parsing into one place and update the callers, without changing behavior. The solution is at Fixture.sln."
|
||||
setup:
|
||||
copy_test_files: true
|
||||
assertions:
|
||||
- type: "output_matches"
|
||||
pattern: "(public API|PublicAPI\\.Shipped|breaking change|shipped)"
|
||||
- type: "file_contains"
|
||||
path: "src/Billing/PublicAPI.Shipped.txt"
|
||||
value: "AppSettingsHelper"
|
||||
# Public-surface preservation gate: the shipped members are exercised by the
|
||||
# tests (AppSettingsHelper + ConfigReader). A "safe" consolidation keeps them
|
||||
# working; silently removing/renaming a shipped public member breaks the test
|
||||
# compile and fails here.
|
||||
- type: run_command_and_assert
|
||||
command_to_run: "dotnet"
|
||||
command_arguments: "test Fixture.sln -v:q"
|
||||
expected_exit_code: 0
|
||||
expected_std_output_contains: "Passed!"
|
||||
command_timeout: 300
|
||||
rubric:
|
||||
- "Answers the public-API question by actually inspecting what governs the surface (finds and reads `PublicAPI.Shipped.txt`, which lists the public parsing members) rather than assuming"
|
||||
- "Recognizes that removing or renaming a shipped public member is a breaking change a green test run will not catch, and either preserves the public surface (for example keeps the members as thin forwarders) or explicitly flags the break and updates the API baseline — does not silently break the public API"
|
||||
- "If it consolidates, keeps behavior unchanged and re-runs the build and tests to prove it"
|
||||
timeout: 600
|
||||
|
||||
- name: "Add behavior to a shipped public member without breaking the contract"
|
||||
prompt: "Add hex support to `AppSettingsHelper.ParseIntSetting` so callers can parse values like `0x1F` in addition to plain decimals. The solution is at Fixture.sln."
|
||||
setup:
|
||||
copy_test_files: true
|
||||
assertions:
|
||||
- type: "output_matches"
|
||||
pattern: "(public API|PublicAPI|breaking change|shipped|signature|overload)"
|
||||
- type: "file_contains"
|
||||
path: "src/Billing/PublicAPI.Shipped.txt"
|
||||
value: "ParseIntSetting"
|
||||
# The shipped signature must not break: existing decimal-parsing tests must
|
||||
# still pass after hex support is added.
|
||||
- type: run_command_and_assert
|
||||
command_to_run: "dotnet"
|
||||
command_arguments: "test Fixture.sln -v:q"
|
||||
expected_exit_code: 0
|
||||
expected_std_output_contains: "Passed!"
|
||||
command_timeout: 300
|
||||
rubric:
|
||||
- "Treats this as a feature (adds new hex-parsing behavior), not a behavior-preserving refactor, and does not claim behavior is unchanged"
|
||||
- "Recognizes `AppSettingsHelper.ParseIntSetting` is a shipped public member listed in `PublicAPI.Shipped.txt`, and that changing its signature or return type would be a breaking change a green test run will not catch"
|
||||
- "Adds hex support without breaking the shipped signature — extends the body or adds an overload — and if the public surface changes, updates the API baseline (`PublicAPI.Unshipped.txt`) or explicitly flags the break rather than silently altering the shipped contract"
|
||||
- "Keeps the existing tests green and ideally adds coverage for the hex case"
|
||||
timeout: 600
|
||||
|
||||
- name: "Rename a helper referenced from every #if branch of a multi-targeted type"
|
||||
prompt: "The private `Label` helper in `PlatformInfo` is poorly named — rename it to `FormatLabel`. Keep behavior identical. The solution is at Fixture.sln."
|
||||
setup:
|
||||
copy_test_files: true
|
||||
assertions:
|
||||
- type: "file_contains"
|
||||
path: "src/Billing/PlatformInfo.cs"
|
||||
value: "FormatLabel"
|
||||
# Multi-target gate: building the whole solution compiles BOTH net8.0 and
|
||||
# net10.0. `Label` is referenced from both #if branches, so if the rename
|
||||
# missed the branch the default build doesn't compile, the net8.0 build fails.
|
||||
- type: run_command_and_assert
|
||||
command_to_run: "dotnet"
|
||||
command_arguments: "build Fixture.sln -v:q"
|
||||
expected_exit_code: 0
|
||||
command_timeout: 300
|
||||
# Behavior-preservation gate on the built target.
|
||||
- type: run_command_and_assert
|
||||
command_to_run: "dotnet"
|
||||
command_arguments: "test Fixture.sln -v:q"
|
||||
expected_exit_code: 0
|
||||
expected_std_output_contains: "Passed!"
|
||||
command_timeout: 300
|
||||
- type: "output_matches"
|
||||
pattern: "(multi[- ]?target|#if|net8|net10|target framework|TFM|every (branch|target))"
|
||||
rubric:
|
||||
- "Recognizes the project multi-targets (`net8.0;net10.0`) and that `Label` is referenced inside BOTH `#if` branches, so the rename must be replicated across every `#if` branch and target framework — not just the branch the default build compiles"
|
||||
- "Renames by tracking true binding references, then rebuilds ALL target frameworks and re-runs the tests to prove behavior is preserved"
|
||||
- "Keeps this a single, focused rename without bundling unrelated edits"
|
||||
timeout: 600
|
||||
|
||||
- name: "Rename a member of a partial type that a generated part references"
|
||||
prompt: "Rename `Coupons.Redeem` to `Apply` — the name is too generic. Keep behavior identical. The solution is at Fixture.sln."
|
||||
setup:
|
||||
copy_test_files: true
|
||||
assertions:
|
||||
- type: "file_contains"
|
||||
path: "src/Billing/Coupons.cs"
|
||||
value: "Apply"
|
||||
# Positive check that the fix went to the GENERATOR INPUT (the template),
|
||||
# which is where the generated part's reference to the renamed member lives.
|
||||
# Hand-editing the obj/ output would be overwritten on the next build.
|
||||
- type: "file_contains"
|
||||
path: "src/Billing/Coupons.g.cs.template"
|
||||
value: "Apply"
|
||||
# Full-propagation gate across the partial type: building the solution
|
||||
# regenerates the partial from the template. If the template (or any caller)
|
||||
# still references the old name, the regenerated code fails to compile.
|
||||
- type: run_command_and_assert
|
||||
command_to_run: "dotnet"
|
||||
command_arguments: "test Fixture.sln -v:q"
|
||||
expected_exit_code: 0
|
||||
expected_std_output_contains: "Passed!"
|
||||
command_timeout: 300
|
||||
- type: "output_matches"
|
||||
pattern: "(auto[- ]?generated|source generator|generated (file|output|code)|partial|template|\\.g\\.cs|regenerat)"
|
||||
rubric:
|
||||
- "Includes EVERY partial declaration of `Coupons` when renaming, and finds the reference in the generated part (which references the renamed member)"
|
||||
- "Recognizes the generated part is emitted at build time from `Coupons.g.cs.template`: flags that hand-editing the generated output under `obj/` is unsafe (it is overwritten on the next build) and that the correct fix edits the template (the generator input) — not the emitted file"
|
||||
- "Rebuilds and re-runs the tests, letting the compiler catch any missed reference, to prove behavior is preserved"
|
||||
timeout: 600
|
||||
|
||||
- name: "Add a new public method that must be correct on every target framework"
|
||||
prompt: "Add a new public method `PlatformInfo.Tag()` that returns just the short platform tag (for example `net10` or `net8`) without the `platform:` prefix. It must return the correct tag on every target framework. The solution is at Fixture.sln."
|
||||
setup:
|
||||
copy_test_files: true
|
||||
assertions:
|
||||
- type: "file_contains"
|
||||
path: "src/Billing/PlatformInfo.cs"
|
||||
value: "Tag"
|
||||
# Multi-target gate: the new method must compile on BOTH net8.0 and net10.0.
|
||||
- type: run_command_and_assert
|
||||
command_to_run: "dotnet"
|
||||
command_arguments: "build Fixture.sln -v:q"
|
||||
expected_exit_code: 0
|
||||
command_timeout: 300
|
||||
# Existing behavior stays green after the additive change.
|
||||
- type: run_command_and_assert
|
||||
command_to_run: "dotnet"
|
||||
command_arguments: "test Fixture.sln -v:q"
|
||||
expected_exit_code: 0
|
||||
expected_std_output_contains: "Passed!"
|
||||
command_timeout: 300
|
||||
- type: "output_matches"
|
||||
pattern: "(multi[- ]?target|#if|net8|net10|target framework|TFM|public API|PublicAPI)"
|
||||
rubric:
|
||||
- "Treats this as a feature addition (a new public method), not a behavior-preserving refactor"
|
||||
- "Recognizes the project multi-targets (`net8.0;net10.0`) and uses `#if` branches, and implements `Tag()` so it returns the correct value on EVERY target framework — building and testing each target, not just the one the default build compiles"
|
||||
- "Recognizes `Tag()` is a NEW public member of a shipped type (public API surface) and updates the API baseline (`PublicAPI.Unshipped.txt`) or flags the surface addition"
|
||||
- "Keeps existing tests green and adds coverage for the new method"
|
||||
timeout: 600
|
||||
|
||||
- name: "Do not raise breaking-change concerns for a purely internal local rename"
|
||||
prompt: "Inside `OrderProcessor.DoStuff`, the local variable `subtotal` is a bit terse. Rename that local to `runningTotal` for readability. Keep behavior identical. The solution is at Fixture.sln."
|
||||
expect_activation: false
|
||||
setup:
|
||||
copy_test_files: true
|
||||
assertions:
|
||||
- type: "file_contains"
|
||||
path: "src/Billing/OrderProcessor.cs"
|
||||
value: "runningTotal"
|
||||
# The local rename is behavior-identical: tests must still pass unchanged.
|
||||
- type: run_command_and_assert
|
||||
command_to_run: "dotnet"
|
||||
command_arguments: "test Fixture.sln -v:q"
|
||||
expected_exit_code: 0
|
||||
expected_std_output_contains: "Passed!"
|
||||
command_timeout: 300
|
||||
rubric:
|
||||
- "Recognizes that renaming a method-local variable touches no public API surface, no `#if`/multi-target branch, and no generated code, so no breaking-change hazard applies"
|
||||
- "Performs the local rename directly without introducing public-API baseline edits, source-generator changes, or other breaking-change ceremony that the change does not warrant"
|
||||
- "Confirms behavior is unchanged by rebuilding and re-running the tests"
|
||||
timeout: 300
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Billing;
|
||||
|
||||
/// <summary>Parses application settings, returning a fallback when the raw value is missing or invalid.</summary>
|
||||
public static class AppSettingsHelper
|
||||
{
|
||||
public static int ParseIntSetting(string? raw, int fallback)
|
||||
=> int.TryParse(raw, out var v) ? v : fallback;
|
||||
|
||||
public static bool ParseBoolSetting(string? raw, bool fallback)
|
||||
=> bool.TryParse(raw, out var v) ? v : fallback;
|
||||
}
|
||||
|
||||
/// <summary>Reads configuration values, returning a fallback when the raw value is missing or invalid.</summary>
|
||||
public static class ConfigReader
|
||||
{
|
||||
public static int ReadInt(string? raw, int fallback)
|
||||
=> int.TryParse(raw, out var v) ? v : fallback;
|
||||
|
||||
public static bool ReadBool(string? raw, bool fallback)
|
||||
=> bool.TryParse(raw, out var v) ? v : fallback;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>true</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Coupons.g.cs.template" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="GenerateCouponsPartial" BeforeTargets="CoreCompile">
|
||||
<Copy SourceFiles="Coupons.g.cs.template"
|
||||
DestinationFiles="$(IntermediateOutputPath)Coupons.g.cs"
|
||||
SkipUnchangedFiles="true" />
|
||||
<ItemGroup>
|
||||
<Compile Include="$(IntermediateOutputPath)Coupons.g.cs" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Billing;
|
||||
|
||||
/// <summary>Hand-authored part of the <c>Coupons</c> partial type.</summary>
|
||||
public partial class Coupons
|
||||
{
|
||||
public decimal Redeem(string code, decimal amount)
|
||||
=> amount - (amount * RateFor(code));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// <auto-generated/>
|
||||
namespace Billing;
|
||||
|
||||
public partial class Coupons
|
||||
{
|
||||
private static decimal RateFor(string code) => code switch
|
||||
{
|
||||
"SAVE10" => 0.10m,
|
||||
"SAVE05" => 0.05m,
|
||||
_ => 0.00m,
|
||||
};
|
||||
|
||||
public decimal RedeemDefault(decimal amount) => Redeem("SAVE10", amount);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace Billing;
|
||||
|
||||
/// <summary>A single line item on an order.</summary>
|
||||
public readonly record struct OrderLine(string Sku, decimal UnitPrice, int Quantity);
|
||||
|
||||
/// <summary>The computed result of pricing an order.</summary>
|
||||
public readonly record struct Invoice(decimal Total, decimal Quote, decimal Shipping);
|
||||
|
||||
public sealed class OrderProcessor
|
||||
{
|
||||
public Invoice DoStuff(IReadOnlyList<OrderLine> lines, string tier)
|
||||
{
|
||||
decimal subtotal = 0m;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
subtotal += line.UnitPrice * line.Quantity;
|
||||
}
|
||||
|
||||
decimal shipping = subtotal >= 100m ? 0m : 9.99m;
|
||||
|
||||
decimal rateA = tier == "gold" ? 0.10m : tier == "silver" ? 0.05m : 0.00m;
|
||||
decimal discountedA = subtotal - (subtotal * rateA);
|
||||
decimal totalWithTax = discountedA + (discountedA * 0.08m);
|
||||
|
||||
decimal quoteBase = subtotal + shipping;
|
||||
|
||||
decimal rateB = tier == "gold" ? 0.10m : tier == "silver" ? 0.05m : 0.00m;
|
||||
decimal discountedB = quoteBase - (quoteBase * rateB);
|
||||
decimal quoteWithTax = discountedB + (discountedB * 0.08m);
|
||||
|
||||
return new Invoice(
|
||||
Math.Round(totalWithTax, 2),
|
||||
Math.Round(quoteWithTax, 2),
|
||||
shipping);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Billing;
|
||||
|
||||
/// <summary>Reports a platform label for the current target framework.</summary>
|
||||
public static class PlatformInfo
|
||||
{
|
||||
private const string ModernTag = "net10";
|
||||
private const string LegacyTag = "net8";
|
||||
|
||||
public static string Current()
|
||||
{
|
||||
#if NET8_0
|
||||
return Label(LegacyTag);
|
||||
#else
|
||||
return Label(ModernTag);
|
||||
#endif
|
||||
}
|
||||
|
||||
private static string Label(string tag) => $"platform:{tag}";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Billing;
|
||||
|
||||
/// <summary>The canonical tax rule.</summary>
|
||||
public static class TaxRules
|
||||
{
|
||||
public static decimal Apply(decimal amount) => amount + (amount * 0.08m);
|
||||
}
|
||||
|
||||
/// <summary>Legacy tax entry point retained for older callers.</summary>
|
||||
public static class LegacyTax
|
||||
{
|
||||
public static decimal ApplyTaxWrapper(decimal amount) => TaxRules.Apply(amount);
|
||||
}
|
||||
|
||||
public sealed class GoldPricing
|
||||
{
|
||||
public decimal Rate => 0.10m;
|
||||
public string Name => "gold";
|
||||
}
|
||||
|
||||
public sealed class SilverPricing
|
||||
{
|
||||
public decimal Rate => 0.05m;
|
||||
public string Name => "silver";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#nullable enable
|
||||
Billing.AppSettingsHelper
|
||||
static Billing.AppSettingsHelper.ParseIntSetting(string? raw, int fallback) -> int
|
||||
static Billing.AppSettingsHelper.ParseBoolSetting(string? raw, bool fallback) -> bool
|
||||
Billing.ConfigReader
|
||||
static Billing.ConfigReader.ReadInt(string? raw, int fallback) -> int
|
||||
static Billing.ConfigReader.ReadBool(string? raw, bool fallback) -> bool
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Billing\Billing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,111 @@
|
||||
using Billing;
|
||||
using Xunit;
|
||||
|
||||
namespace Billing.Tests;
|
||||
|
||||
public class BillingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Invoice_NoTier_ComputesTotalAndQuote()
|
||||
{
|
||||
var processor = new OrderProcessor();
|
||||
var lines = new[]
|
||||
{
|
||||
new OrderLine("A", 50m, 1),
|
||||
new OrderLine("B", 20m, 2),
|
||||
};
|
||||
|
||||
var invoice = processor.DoStuff(lines, "none");
|
||||
|
||||
// subtotal = 90, shipping = 9.99 (subtotal < 100)
|
||||
// total = 90 * 1.08 = 97.20
|
||||
// quote = (90 + 9.99) * 1.08 = 107.9892 -> 107.99
|
||||
Assert.Equal(97.20m, invoice.Total);
|
||||
Assert.Equal(107.99m, invoice.Quote);
|
||||
Assert.Equal(9.99m, invoice.Shipping);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invoice_Gold_AppliesDiscountAndFreeShipping()
|
||||
{
|
||||
var processor = new OrderProcessor();
|
||||
var lines = new[] { new OrderLine("A", 100m, 2) };
|
||||
|
||||
var invoice = processor.DoStuff(lines, "gold");
|
||||
|
||||
// subtotal = 200, shipping = 0 (subtotal >= 100)
|
||||
// discounted = 200 * 0.90 = 180, total = 180 * 1.08 = 194.40
|
||||
// quote base = 200 -> same as total = 194.40
|
||||
Assert.Equal(194.40m, invoice.Total);
|
||||
Assert.Equal(194.40m, invoice.Quote);
|
||||
Assert.Equal(0m, invoice.Shipping);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invoice_Silver_AppliesFivePercentDiscount()
|
||||
{
|
||||
var processor = new OrderProcessor();
|
||||
var lines = new[] { new OrderLine("A", 200m, 1) };
|
||||
|
||||
var invoice = processor.DoStuff(lines, "silver");
|
||||
|
||||
// subtotal = 200, shipping = 0
|
||||
// discounted = 200 * 0.95 = 190, total = 190 * 1.08 = 205.20
|
||||
Assert.Equal(205.20m, invoice.Total);
|
||||
Assert.Equal(205.20m, invoice.Quote);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tax_Wrapper_MatchesUnderlyingRule()
|
||||
{
|
||||
Assert.Equal(TaxRules.Apply(100m), LegacyTax.ApplyTaxWrapper(100m));
|
||||
Assert.Equal(108m, LegacyTax.ApplyTaxWrapper(100m));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PricingTiers_HaveExpectedRatesAndNames()
|
||||
{
|
||||
Assert.Equal(0.10m, new GoldPricing().Rate);
|
||||
Assert.Equal("gold", new GoldPricing().Name);
|
||||
Assert.Equal(0.05m, new SilverPricing().Rate);
|
||||
Assert.Equal("silver", new SilverPricing().Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatformInfo_Current_ReportsModernTagUnderNet10()
|
||||
{
|
||||
// The test project targets net10.0, so the #else branch is active.
|
||||
Assert.Equal("platform:net10", PlatformInfo.Current());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppSettingsHelper_ParsesOrFallsBack()
|
||||
{
|
||||
Assert.Equal(42, AppSettingsHelper.ParseIntSetting("42", 0));
|
||||
Assert.Equal(7, AppSettingsHelper.ParseIntSetting("nope", 7));
|
||||
Assert.True(AppSettingsHelper.ParseBoolSetting("true", false));
|
||||
Assert.False(AppSettingsHelper.ParseBoolSetting("bad", false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigReader_MatchesAppSettingsHelper()
|
||||
{
|
||||
Assert.Equal(AppSettingsHelper.ParseIntSetting("10", 0), ConfigReader.ReadInt("10", 0));
|
||||
Assert.Equal(AppSettingsHelper.ParseBoolSetting("true", false), ConfigReader.ReadBool("true", false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Coupons_Redeem_AppliesRate()
|
||||
{
|
||||
var coupons = new Coupons();
|
||||
Assert.Equal(90m, coupons.Redeem("SAVE10", 100m));
|
||||
Assert.Equal(95m, coupons.Redeem("SAVE05", 100m));
|
||||
Assert.Equal(100m, coupons.Redeem("UNKNOWN", 100m));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Coupons_RedeemDefault_UsesSave10()
|
||||
{
|
||||
Assert.Equal(90m, new Coupons().RedeemDefault(100m));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user