DiscountedOrder domain type + DiscountCalculator (half-up, strict barrier) with unit tests
yak: discountedorder-discountcalculator-with-unit-tests-zrtn
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
namespace After;
|
||||
|
||||
/// <summary>
|
||||
/// Domain service computing a discount for an <see cref="Order"/>. Pure
|
||||
/// calculation: it reads the order and returns a <see cref="DiscountedOrder"/>;
|
||||
/// it never mutates the order. Copying the result onto the order — and what
|
||||
/// that does or does not persist — is the exercise (see EXERCISE.md).
|
||||
/// </summary>
|
||||
public static class DiscountCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculate the discount for <paramref name="order"/>. The discount
|
||||
/// applies only when the order's total is <em>strictly greater</em> than
|
||||
/// the barrier — an order at exactly the barrier gets none.
|
||||
/// </summary>
|
||||
/// <param name="order">The order to discount; its <see cref="Order.TotalInCents"/> is the input.</param>
|
||||
/// <param name="discountBarrierCents">Minimum total (in cents) required to qualify; the total must exceed it.</param>
|
||||
/// <param name="discountPercentage">The discount as an integer percent (10 means 10 %), 0–100.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="DiscountedOrder"/> correlated to the order by id.
|
||||
/// <see cref="DiscountedOrder.DiscountPercentage"/> is 0 when the barrier
|
||||
/// was not passed, and <see cref="DiscountedOrder.DiscountedTotalInCents"/>
|
||||
/// equals the total in that case.
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="order"/> is null.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// The total or barrier is negative, or the percentage is outside 0–100.
|
||||
/// </exception>
|
||||
/// <remarks>
|
||||
/// The discount amount is rounded <b>half-up</b> with pure integer math —
|
||||
/// <c>(total × percentage + 50) / 100</c> — so 10 % of 155 cents is a
|
||||
/// 16-cent discount (15.5 rounds up), and no floating point is involved.
|
||||
/// </remarks>
|
||||
public static DiscountedOrder CalculateDiscount(Order order, int discountBarrierCents, int discountPercentage)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(order);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(order.TotalInCents);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(discountBarrierCents);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(discountPercentage, 0);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(discountPercentage, 100);
|
||||
|
||||
if (order.TotalInCents <= discountBarrierCents)
|
||||
return new DiscountedOrder
|
||||
{
|
||||
OrderId = order.Id,
|
||||
TotalInCents = order.TotalInCents,
|
||||
DiscountPercentage = 0,
|
||||
DiscountedTotalInCents = order.TotalInCents,
|
||||
};
|
||||
|
||||
// Half-up rounding in integers: add half of the divisor before the
|
||||
// integer division. 64-bit intermediate: cents × percent can overflow
|
||||
// 32 bits for large orders.
|
||||
var discount = (int)(((long)order.TotalInCents * discountPercentage + 50) / 100);
|
||||
|
||||
return new DiscountedOrder
|
||||
{
|
||||
OrderId = order.Id,
|
||||
TotalInCents = order.TotalInCents,
|
||||
DiscountPercentage = discountPercentage,
|
||||
DiscountedTotalInCents = order.TotalInCents - discount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace After;
|
||||
|
||||
/// <summary>
|
||||
/// The result of discounting an <see cref="Order"/>: a separate domain type,
|
||||
/// not a modified order. The discount is derived data — computed on demand,
|
||||
/// correlated to its order by <see cref="OrderId"/>. What a caller does with
|
||||
/// it (and what of it ever reaches <see cref="OrderDto"/> or
|
||||
/// <see cref="OrderData"/>) is deliberately left to the caller.
|
||||
/// </summary>
|
||||
public sealed record DiscountedOrder
|
||||
{
|
||||
/// <summary>Primary key of the discounted <see cref="Order"/>.</summary>
|
||||
public Guid OrderId { get; init; }
|
||||
|
||||
/// <summary>The order's total in cents, as read from it (the untouched input).</summary>
|
||||
public int TotalInCents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The percentage actually applied, as an integer percent. 0 when the
|
||||
/// order did not pass the discount barrier.
|
||||
/// </summary>
|
||||
public int DiscountPercentage { get; init; }
|
||||
|
||||
/// <summary>The total after the discount, in cents.</summary>
|
||||
public int DiscountedTotalInCents { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using After;
|
||||
|
||||
namespace BeforeAfter.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DiscountCalculator.CalculateDiscount"/>:
|
||||
/// barrier semantics (strictly greater), integer-percent units, half-up
|
||||
/// rounding of the discount amount, input validation, and the guarantee that
|
||||
/// the calculation never mutates the order.
|
||||
/// </summary>
|
||||
public class DiscountCalculatorTests
|
||||
{
|
||||
// ---------------------------------------------------------------------
|
||||
// The barrier: only a total strictly greater than it is discounted.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void A_total_below_the_barrier_gets_no_discount()
|
||||
{
|
||||
var order = new Order { Id = Guid.NewGuid(), Description = "small", TotalInCents = 50_00 };
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
Assert.Equal(0, discounted.DiscountPercentage);
|
||||
Assert.Equal(50_00, discounted.DiscountedTotalInCents);
|
||||
Assert.Equal(50_00, discounted.TotalInCents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_total_exactly_at_the_barrier_gets_no_discount()
|
||||
{
|
||||
var order = new Order { Id = Guid.NewGuid(), TotalInCents = 100_00 };
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
Assert.Equal(0, discounted.DiscountPercentage);
|
||||
Assert.Equal(100_00, discounted.DiscountedTotalInCents);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Above the barrier: percentage applied, total reduced.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void A_total_above_the_barrier_gets_the_percentage_discount()
|
||||
{
|
||||
var order = new Order { Id = Guid.NewGuid(), TotalInCents = 150_00 };
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
Assert.Equal(10, discounted.DiscountPercentage);
|
||||
Assert.Equal(135_00, discounted.DiscountedTotalInCents); // 10 % of 150 = 15 off
|
||||
Assert.Equal(150_00, discounted.TotalInCents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_zero_percentage_discounts_nothing_even_above_the_barrier()
|
||||
{
|
||||
var order = new Order { Id = Guid.NewGuid(), TotalInCents = 150_00 };
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 0);
|
||||
|
||||
Assert.Equal(0, discounted.DiscountPercentage);
|
||||
Assert.Equal(150_00, discounted.DiscountedTotalInCents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hundred_percent_discount_leaves_nothing()
|
||||
{
|
||||
var order = new Order { Id = Guid.NewGuid(), TotalInCents = 150_00 };
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 100);
|
||||
|
||||
Assert.Equal(100, discounted.DiscountPercentage);
|
||||
Assert.Equal(0, discounted.DiscountedTotalInCents);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Half-up rounding of the discount amount (integer math, no float).
|
||||
// ---------------------------------------------------------------------
|
||||
[Theory]
|
||||
[InlineData(155, 16, 139)] // 15.5 rounds UP to 16
|
||||
[InlineData(145, 15, 130)] // 14.5 rounds UP to 15
|
||||
[InlineData(154, 15, 139)] // 15.4 rounds down to 15
|
||||
[InlineData(151, 15, 136)] // 15.1 rounds down to 15
|
||||
public void The_discount_amount_is_rounded_half_up(int totalInCents, int expectedDiscountCents, int expectedDiscountedTotal)
|
||||
{
|
||||
var order = new Order { Id = Guid.NewGuid(), TotalInCents = totalInCents };
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100, discountPercentage: 10);
|
||||
|
||||
Assert.Equal(totalInCents - expectedDiscountCents, discounted.DiscountedTotalInCents);
|
||||
Assert.Equal(expectedDiscountedTotal, discounted.DiscountedTotalInCents);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Correlation and purity.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void The_result_is_correlated_to_the_order_by_id()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var order = new Order { Id = id, TotalInCents = 150_00 };
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
Assert.Equal(id, discounted.OrderId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculating_does_not_mutate_the_order()
|
||||
{
|
||||
var order = new Order { Id = Guid.NewGuid(), Description = "one", TotalInCents = 150_00 };
|
||||
|
||||
DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
Assert.Equal("one", order.Description);
|
||||
Assert.Equal(150_00, order.TotalInCents);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Input validation: invalid arguments throw (loudly, not silently).
|
||||
// ---------------------------------------------------------------------
|
||||
[Theory]
|
||||
[InlineData(-1, 100_00, 10)] // negative total
|
||||
[InlineData(150_00, -1, 10)] // negative barrier
|
||||
[InlineData(150_00, 100_00, -1)]// percentage below 0
|
||||
[InlineData(150_00, 100_00, 101)]// percentage above 100
|
||||
public void Invalid_arguments_throw(int totalInCents, int discountBarrierCents, int discountPercentage)
|
||||
{
|
||||
var order = new Order { Id = Guid.NewGuid(), TotalInCents = totalInCents };
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => DiscountCalculator.CalculateDiscount(order, discountBarrierCents, discountPercentage));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_null_order_throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => DiscountCalculator.CalculateDiscount(null!, discountBarrierCents: 100_00, discountPercentage: 10));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user