Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c17984f1 | ||
|
|
ff6dbfb269 | ||
|
|
08b52806de | ||
|
|
53586d33a4 | ||
|
|
37ebb21182 | ||
|
|
d39426d328 | ||
|
|
803580786b | ||
|
|
49f4ca395f | ||
|
|
9895ef87e4 | ||
|
|
01ba13f94d | ||
|
|
5270eabcf3 | ||
|
|
804695f12d | ||
|
|
1188ce4e57 | ||
|
|
e15962d86a |
@@ -6,3 +6,4 @@ obj/
|
||||
*.user
|
||||
# NuGet restore scratch dir (DOTNET_CLI_HOME fallout)
|
||||
.local/
|
||||
.idea
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
# Exercise: the discount that wouldn't map
|
||||
|
||||
You are in the **"after" situation** of this code base: the domain objects
|
||||
(`Client`, `Order`) are plain — no `DbBase` inheritance, no knowledge of the
|
||||
`DbContext`. Two hand-written mapping families lead out of the domain:
|
||||
|
||||
- `OrderDto` / `ClientDto` — transport shapes consumed by the WebApp;
|
||||
- `OrderData` / `ClientData` — the data-only records the persistence framework
|
||||
stores (one row per object, object graph flattened to ids).
|
||||
|
||||
The *before* situation was Active Record: the domain object and the stored
|
||||
shape were one and the same class, so there was nothing to map. Now mapping is
|
||||
explicit — and that is where this exercise lives.
|
||||
|
||||
## What you get
|
||||
|
||||
- `Order.TotalInCents` — the order total, an integer in cents, persisted and
|
||||
mapped one-on-one into `OrderDto` and `OrderData`.
|
||||
- `DiscountCalculator.CalculateDiscount(order, discountBarrierCents, discountPercentage)` —
|
||||
a pure domain service. It returns a **`DiscountedOrder`** (a separate domain
|
||||
type, correlated to the order by `OrderId`) carrying the order's total, the
|
||||
percentage *actually applied* (0 when the total does not strictly exceed the
|
||||
barrier), and the discounted total. Rounding is half-up, in integer math.
|
||||
- Unit tests for all of the above already pass.
|
||||
- `ValidOrder()` / `ValidClient()` test data builders in the test project
|
||||
(`tests/BeforeAfter.Tests/TestData/`) — construct domain objects in tests
|
||||
through them, so tests survive domain evolution. Import their static
|
||||
factories with `using static BeforeAfter.Tests.TestData.OrderBuilder;`
|
||||
(and `...ClientBuilder;`).
|
||||
|
||||
## The challenge
|
||||
|
||||
The `DiscountedOrder` is **not** a one-on-one copy of the order, and it must
|
||||
not become one:
|
||||
|
||||
- `Order` needs to learn the *discounted total* — but neither `OrderDto` nor
|
||||
`OrderData` may gain it. The discount is derived data: it lives in the
|
||||
domain only, and the storage schema stays untouched.
|
||||
- The `DiscountedOrder.DiscountPercentage` is **not** copied onto the order —
|
||||
it can be derived from the two totals (and `TotalInCents` never changes).
|
||||
|
||||
So the flow is: order → `DiscountedOrder` (calculate) → order (correlate +
|
||||
copy, dropping what is derivable) → persistence (which stores *less* than the
|
||||
domain now knows).
|
||||
|
||||
Work through the three steps below — each is small enough to hand to a coding
|
||||
agent as a single instruction. Run the tests after each step:
|
||||
|
||||
```sh
|
||||
scripts/run-tests.sh
|
||||
```
|
||||
|
||||
## Step 1 — teach the order its discounted total
|
||||
|
||||
Add a single property `DiscountedTotalInCents` (`int`, default 0) to the
|
||||
**domain** `Order` in `src/After/Order.cs`. Do not touch `OrderDto`,
|
||||
`OrderData`, or the mappers: the discounted total must exist in the domain
|
||||
only. Acceptance: the solution builds and the full test suite still passes.
|
||||
|
||||
## Step 2 — correlate a discounted order onto its order
|
||||
|
||||
Create `tests/BeforeAfter.Tests/DiscountedOrderMappingTests.cs` from the stub
|
||||
below and make the **first** test pass. The point is the correlation: the
|
||||
`DiscountedOrder` knows the id of the order it was calculated from — use it
|
||||
instead of assuming there is only one order around.
|
||||
|
||||
```csharp
|
||||
using After;
|
||||
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||
|
||||
namespace BeforeAfter.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// EXERCISE — the mapping from a DiscountedOrder onto an Order is NOT
|
||||
/// one-on-one. Make these tests pass, following EXERCISE.md step by step.
|
||||
/// </summary>
|
||||
public class DiscountedOrderMappingTests
|
||||
{
|
||||
[Fact] // EXERCISE STEP 2
|
||||
public void A_discounted_order_can_be_correlated_onto_its_order()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
// 10 % for orders above €100:
|
||||
var discounted = DiscountCalculator.CalculateDiscount(
|
||||
order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
// TODO (step 2): correlate the discounted order to the saved order and
|
||||
// copy the discounted total onto the order.
|
||||
// Hints: DiscountedOrder.OrderId identifies the order; the discount
|
||||
// percentage is deliberately NOT copied — it is derivable.
|
||||
|
||||
// ... your code here ...
|
||||
|
||||
Assert.Equal(135_00, order.DiscountedTotalInCents);
|
||||
}
|
||||
|
||||
[Fact] // EXERCISE STEP 3
|
||||
public void Saving_a_discounted_order_keeps_the_stored_total_untouched()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(
|
||||
order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
// TODO (step 3): correlate + copy as in step 2, then save the updated
|
||||
// order through the context. Afterwards find the order's persisted
|
||||
// OrderData row (hint: db.Find<OrderData>(...)) and assert that the
|
||||
// stored total is STILL the original 150_00: the database knows
|
||||
// nothing about discounts — derived data lives in the domain only.
|
||||
|
||||
// ... your code here ...
|
||||
|
||||
OrderData row = null!; // TODO (step 3): find the OrderData row for this order
|
||||
Assert.Equal(150_00, row.TotalInCents);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Acceptance: the first test is green, the second still red.
|
||||
|
||||
## Step 3 — save, and check what actually persisted
|
||||
|
||||
Make the **second** test pass: re-save the updated order through the context
|
||||
(the context refreshes the stored row in place), then assert on the persisted
|
||||
`OrderData` row. Note what you are *not* asserting: there is no discounted
|
||||
column to check — the row simply keeps the original total.
|
||||
|
||||
Acceptance: the whole suite is green — including the pre-existing tests, which
|
||||
guard exactly the invariants you must not break (the DTO and the persistence
|
||||
records still map the same fields as before).
|
||||
|
||||
## Facilitator notes
|
||||
|
||||
- Expected end state per step: (1) one new domain-only property; (2) a mapping
|
||||
fragment *outside* the `Mappers` class — correlation by id plus a field
|
||||
copy, dropping the derivable percentage; (3) `db.Save(order)` after which
|
||||
the `OrderData` row still shows `TotalInCents == 150_00`.
|
||||
- Discussion points: why two mapping families out of the domain (transport vs
|
||||
persistence) and why neither carries the discount; what an Active Record
|
||||
"before" world would have done here (the discounted value would have been
|
||||
a column, or nothing at all); what happens to `DiscountedTotalInCents` when
|
||||
the order travels to the WebApp and back (the DTO round-trip silently drops
|
||||
it — derived data must be recomputed, not transported).
|
||||
- The unit tests in `DiscountCalculatorTests.cs` pin the calculation contract
|
||||
(strict barrier, half-up rounding, validation); participants should not need
|
||||
to touch them.
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
# Exercise 2: OrderLines — the domain evolves, the edges don't
|
||||
|
||||
> Run `EXERCISE.md` first (or apply its solution from the
|
||||
> `exercise-1-solution` branch): this exercise continues from that state.
|
||||
|
||||
You are in the "after" situation: plain domain objects, two mapping families
|
||||
out of the domain (DTOs for transport, `*Data` records for persistence), and
|
||||
test data builders so tests don't reach into domain construction. The order
|
||||
total is a stored `int TotalInCents`.
|
||||
|
||||
The scenario: orders should consist of **order lines** — each with its own
|
||||
description and amount. The total becomes *derived* data: the sum of the
|
||||
lines. And here is the point of the exercise — the persistence framework and
|
||||
the DTO keep their flat shape (one `Description`, one `TotalInCents` per
|
||||
order), so the domain changes while **nothing at the edges does**: same DTO,
|
||||
same storage row, same WebApp.
|
||||
|
||||
## The trick, and the accepted loss
|
||||
|
||||
Storage holds one flat row per order. Reading an order back therefore
|
||||
reconstructs it with **one synthetic order line**: description and amount
|
||||
taken from the row's `Description` and `TotalInCents`. Calculation (discount,
|
||||
totals) then just works on reloaded orders.
|
||||
|
||||
The honest limitation: an order with **more than one line** cannot round-trip
|
||||
through storage — saving flattens it, reading back collapses it to one line.
|
||||
That is fine as long as multi-line orders never reach the front-end. The
|
||||
exercise includes a test that documents this loss instead of hiding it.
|
||||
|
||||
Work through the three steps — each is one instruction for a coding agent.
|
||||
Run the tests after each step:
|
||||
|
||||
```sh
|
||||
scripts/run-tests.sh
|
||||
```
|
||||
|
||||
## Step 1 — introduce OrderLines (and meet the compiler)
|
||||
|
||||
In `src/After`:
|
||||
|
||||
1. Add an `OrderLine` — a plain, mutable domain type in the same style as
|
||||
`Order`/`Client` (only the DTOs are records):
|
||||
|
||||
```csharp
|
||||
public class OrderLine(string description, int amountInCents)
|
||||
{
|
||||
public string Description { get; set; } = description;
|
||||
public int AmountInCents { get; set; } = amountInCents;
|
||||
}
|
||||
```
|
||||
|
||||
2. In `Order`: keep `Description` (it stays stored — it is the order-level
|
||||
description), add `public List<OrderLine> Lines { get; } = [];`, and turn
|
||||
the total into a computed property (delete the stored one):
|
||||
|
||||
```csharp
|
||||
public int TotalInCents => Lines.Sum(line => line.AmountInCents);
|
||||
```
|
||||
|
||||
3. Update `OrderBuilder`: `WithTotal` must keep its API but can no longer set
|
||||
a computed total. Store the request and express it in `Build()` as a single
|
||||
line carrying the order's description — so `WithDescription`/`WithTotal`
|
||||
work in any order:
|
||||
|
||||
```csharp
|
||||
private int? _totalInCents;
|
||||
public OrderBuilder WithTotal(int totalInCents) { _totalInCents = totalInCents; return this; }
|
||||
public Order Build()
|
||||
{
|
||||
if (_totalInCents is int total)
|
||||
_order.Lines.Add(new OrderLine(_order.Description, total));
|
||||
return _order;
|
||||
}
|
||||
```
|
||||
|
||||
Now build. The compiler enumerates every remaining *writer* of the total —
|
||||
exactly two, both **inbound** mappers (`Mappers.FromOrderDto` and
|
||||
`Mappers.FromOrderData`). Everything that only *reads* the total —
|
||||
`ToOrderDto`, `ToOrderData`, `DiscountCalculator`, the WebApp — compiles
|
||||
untouched. That is the whole lesson in one compiler output.
|
||||
|
||||
One test also writes the total (`order.TotalInCents = 99_00` in
|
||||
`Saving_again_refreshes_the_row_instead_of_duplicating_it`); fix it now by
|
||||
mutating through the new domain API instead: `order.Lines[0].AmountInCents = 99_00;`
|
||||
|
||||
Acceptance: the **only** remaining build errors are the two inbound mappers.
|
||||
|
||||
## Step 2 — reconstruct instead of write
|
||||
|
||||
A DTO (and a storage row) carries one description and one flat total, so the
|
||||
reconstructed order is an order with **one line** — derived data cannot be
|
||||
written, only re-derived. In both `Mappers.FromOrderDto` (`UnmapOrder`) and
|
||||
`Mappers.FromOrderData`: keep the field copies as they are, drop the
|
||||
`TotalInCents = ...` assignment, and after creating the order add
|
||||
|
||||
```csharp
|
||||
order.Lines.Add(new OrderLine(dto.Description, dto.TotalInCents)); // dto / row respectively
|
||||
```
|
||||
|
||||
(always exactly one line — even for a zero total — so the reconstruction is
|
||||
deterministic). `DbContext.LoadOrder` / `LoadClient` need no changes.
|
||||
|
||||
Acceptance: the full suite is green again, and `git diff --stat` shows the
|
||||
change confined to the domain, the builder, that one test, and the two
|
||||
inbound mapper bodies — `OrderDto`, `OrderData`, `DbContext`, `WebApp`, and
|
||||
the outbound mappers are untouched.
|
||||
|
||||
## Step 3 — prove it: discount a reloaded order, and document the loss
|
||||
|
||||
Create `tests/BeforeAfter.Tests/OrderLinesEvolutionTests.cs` from the stub
|
||||
below and make both tests pass:
|
||||
|
||||
```csharp
|
||||
using After;
|
||||
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||
|
||||
namespace BeforeAfter.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// EXERCISE 2 — the domain gained OrderLines; persistence still stores one
|
||||
/// flat row per order. Make these tests pass (see EXERCISE2.md).
|
||||
/// </summary>
|
||||
public class OrderLinesEvolutionTests
|
||||
{
|
||||
[Fact] // EXERCISE STEP 3a
|
||||
public void A_reloaded_order_can_be_discounted()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
Order loaded = null!; // TODO (step 3a): load the order back from the context
|
||||
|
||||
// The discount must work off the computed total of the RELOADED order:
|
||||
var discounted = DiscountCalculator.CalculateDiscount(
|
||||
loaded, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
Assert.Equal(135_00, discounted.DiscountedTotalInCents);
|
||||
Assert.Equal(10, discounted.DiscountPercentage);
|
||||
}
|
||||
|
||||
[Fact] // EXERCISE STEP 3b — the accepted loss
|
||||
public void A_multi_line_order_flattens_to_a_single_line_when_stored()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("mixed order").Build();
|
||||
order.Lines.Add(new OrderLine("first item", 100_00));
|
||||
order.Lines.Add(new OrderLine("second item", 50_00));
|
||||
|
||||
db.Save(order);
|
||||
|
||||
Order loaded = null!; // TODO (step 3b): load the order back from the context
|
||||
|
||||
// Storage flattened the order: one line, the flat total, and the
|
||||
// order-level description. Document this — don't hide it.
|
||||
var line = Assert.Single(loaded.Lines);
|
||||
Assert.Equal(150_00, line.AmountInCents);
|
||||
Assert.Equal("mixed order", line.Description);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Acceptance: the whole suite is green — including Exercise 1's tests
|
||||
(`DiscountedOrderMappingTests`), which keep passing unchanged: the discount
|
||||
flow reads the computed total exactly as it read the stored one.
|
||||
|
||||
## Facilitator notes
|
||||
|
||||
- Trainer's solutions live on the `exercise-1-solution` and
|
||||
`exercise-2-solution` branches — keep them off the participant path.
|
||||
|
||||
- Expected diff per step: (1) `OrderLine` + `Order.Lines` + computed total +
|
||||
builder internals + one test line — build red in exactly the two inbound
|
||||
mappers; (2) two one-line reconstructions — green again; (3) two new tests.
|
||||
If `git diff` touches `OrderDto`, `OrderData`, `DbContext`, `WebApp`, or the
|
||||
outbound mappers, something went off the small-steps path.
|
||||
- The `WithTotal` pain the builder absorbs: internally it now fabricates a
|
||||
line; no test noticed. That is the TestDataBuilder dividend.
|
||||
- Discussion points: what the next evolution would look like with a real
|
||||
database (`order_line` rows and a foreign key — the persistence contract
|
||||
would finally change, and only then); why the DTO cannot carry lines back
|
||||
into the domain honestly (derived data is re-derived on the way in, never
|
||||
transported); what the user of a reloaded multi-line order actually gets
|
||||
(the flat total under the order's description) and why that must stay away
|
||||
from the front-end.
|
||||
- `Before` stays untouched throughout — its Active Record world would have
|
||||
needed a schema change on day one, which is the contrast this exercise
|
||||
series is built on.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Living Software LTD (UK)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,54 @@
|
||||
# db-subclass-to-dto
|
||||
|
||||
This is a workspace for creating three exercises.
|
||||
|
||||
## Explore a repository with LLM diagrams
|
||||
|
||||
Explore a repository using prompts for a coding agent to create Mermaid and PlantUML diagrams. We're focusing on class diagrams and sequence diagrams to get an idea of what is going on. We use the starting point of the second exercise for this.
|
||||
|
||||
## Factor out domain objects from ActiveRecord-like structures
|
||||
|
||||
Factor out domain objects, domain entities, from a structure that is previously subclass-based.
|
||||
|
||||
Say you use a structure like Active Record or Entity Framework where you have an active record class and your domain class subclasses from the active record class.
|
||||
|
||||
This super class gives you some easy features like finding an object by attribute. You can quite quickly structure an application while it is also persisted.
|
||||
|
||||
For instance you can say, "I want to have a customer, I want to have an order. And oh yes, I'm doing this for a webshop." So you have objects, relations, attributes, and you can all easily save them in your database.
|
||||
|
||||
This is fine when your application is small and you're just starting out. It allows you to very quickly scaffold an application. But it can become painful when your data doesn't have the right shape for it. So performance is slow. At some point as your application grows, you probably want to be more deliberate in how you deal with data.
|
||||
|
||||
### How do we factor out domain classes?
|
||||
|
||||
To get away from the inheritance structure with an 'abstract' class tied to the database, we need two or three database-bound concrete classes and some relations.
|
||||
|
||||
We pre-deliver some classes that we already migrated to the new structure and we demo it. We create a mapping for writing and reading a domain class. And we make sure that running the existing tests against the fake database that we have for the exercise is **really slow.**
|
||||
|
||||
### Sequence diagrams come in handy
|
||||
|
||||
The sequence diagrams that we made in the first exercise will come in handy. Because in order to do this, you do start wondering how does this web application find a client? How can we see clients? How can we create an order? And what happens to the flow through the application as we do this?
|
||||
|
||||
You need to understand the application flow to choose the kind of mapping that you use. Therefore we are going to reuse the analysis we did in the first exercise. Instructions for participants will be pre-structured, so they can focus on doing the exercise instead of devising exacp prompts.
|
||||
|
||||
## Spike: Static analysis and Extract Method refactoring with Roslyn
|
||||
|
||||
The third part is a spike in creating an extract method refactoring using Roslyn, the C# static analysis tooling that comes as part of C# more or less.
|
||||
|
||||
It's an additional NuGet download but it is quite powerful.
|
||||
|
||||
This is a simple command line app that allows you to specify the name of a file, the starting line and the end line and it will give you an analysis of dependencies inside the method that you need to decide which things could lend itself to extracting to a method, what the parameters could be and what the return value could be.
|
||||
|
||||
This is work in progress so this is not something to share with participants yet, unless it is in the form of a demo after participants have prompted diagrams.
|
||||
|
||||
But it is part of a flow where:
|
||||
|
||||
1. you iterate and you prompt,
|
||||
2. create visualisations
|
||||
3. extract things into a skill
|
||||
4. extract moldable tools to make the skill faster and more reliable
|
||||
|
||||
Skills and tools together create a flywheel where you can do things that happen again and again more reliably and faster. Speed through quality.
|
||||
|
||||
## aside on why skills and tools in the line of moldable development
|
||||
|
||||
You go, ah I often seem to make sequence diagrams. Okay, can we make something deterministic to create these sequence diagrams so it's faster and more reliable? Or in my case I don't like, well I like the extract method stuff in JetBrains IDEs but I need to do some steps before to find the parameters, extract variables, extract the methods with these variables as parameters then inline the variables again and I was wondering if I could do that more smoothly in the flow with a coding agent. That also means that going further I could do things like forbid extracted methods from having Async in all parts, but see if we can extract parts without side effects first.
|
||||
+1
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 47 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"executablePath": "/home/willem/.cache/puppeteer/chrome/linux-152.0.7977.75/chrome-linux64/chrome",
|
||||
"headless": true,
|
||||
"args": [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-gpu",
|
||||
"--disable-gpu-compositing",
|
||||
"--disable-software-rasterizer",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-features=VizDisplayCompositor",
|
||||
"--use-gl=swiftshader",
|
||||
"--disable-gpu-driver-bug-workaround"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Screenshots
|
||||
|
||||
## Flameshot
|
||||
|
||||
Flameshot v12.1.0 is installed at `/usr/bin/flameshot`.
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `flameshot launcher` | Open the capture launcher (recommended) |
|
||||
| `flameshot gui` | Start a manual (rectangular) capture |
|
||||
| `flameshot screen` | Capture a single screen |
|
||||
| `flameshot full` | Capture the entire desktop |
|
||||
| `flameshot config` | Open the configuration dialog |
|
||||
|
||||
Launches a tray icon by default for quick access.
|
||||
|
||||
## Knip
|
||||
|
||||
Knip is **not installed** on this machine. Install it per-project:
|
||||
|
||||
```sh
|
||||
npx knip # dry-run report
|
||||
npx knip --fix # auto-remove unused exports
|
||||
npx knip --config knip.config.ts # custom config
|
||||
```
|
||||
|
||||
Fix npm cache ownership first if you hit `EACCES`:
|
||||
|
||||
```sh
|
||||
sudo chown -R 1000:1000 /home/willem/.npm
|
||||
```
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Render a mermaid diagram to SVG using mmdc with sandbox/GPU workarounds
|
||||
# for Ubuntu 24.04+ (AppArmor restrictions).
|
||||
#
|
||||
# Usage: scripts/mermaid.sh input.mmd [output.svg]
|
||||
# scripts/mermaid.sh input.mmd - # output to stdout
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CONFIG="$REPO_ROOT/puppeteer.config.json"
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <input.mmd> [output.svg|-]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INPUT="$1"
|
||||
shift
|
||||
|
||||
ARGS=("-i" "$INPUT" "-p" "$CONFIG")
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
OUTPUT="$1"
|
||||
shift
|
||||
ARGS+=("-o" "$OUTPUT")
|
||||
fi
|
||||
|
||||
ARGS+=("$@")
|
||||
|
||||
exec mmdc "${ARGS[@]}"
|
||||
+6
-5
@@ -4,9 +4,10 @@ namespace After;
|
||||
/// The DB base class still exists in the "after" situation — it is part of the
|
||||
/// *persistence* layer — but the direction of the dependency is flipped:
|
||||
/// domain objects (<see cref="Client"/>, <see cref="Order"/>) no longer derive
|
||||
/// from it. Only the <see cref="DbContext"/>'s own internal shadow entities do,
|
||||
/// so nothing outside the persistence layer can ever reach a
|
||||
/// <see cref="DbContext"/> through a domain object.
|
||||
/// from it. Only the framework's own data-only persistence records do
|
||||
/// (<see cref="ClientData"/>, <see cref="OrderData"/>): storage wants data
|
||||
/// without behaviour, so the domain keeps the behaviour and the framework
|
||||
/// keeps the data — the mappers bridge the two shapes.
|
||||
/// </summary>
|
||||
public class DbBase
|
||||
{
|
||||
@@ -19,8 +20,8 @@ public class DbBase
|
||||
|
||||
/// <summary>
|
||||
/// Back-reference to the owning <see cref="DbContext"/>. In the after
|
||||
/// situation this lives only on the context's internal shadow entities —
|
||||
/// the plain domain objects that used to inherit it no longer do.
|
||||
/// situation this lives only on the persistence records — the plain
|
||||
/// domain objects that used to inherit it no longer do.
|
||||
/// </summary>
|
||||
public DbContext? DbContext { get; internal set; }
|
||||
}
|
||||
+121
-52
@@ -1,95 +1,164 @@
|
||||
namespace After;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal shadow wrappers — live *inside* the persistence layer so the
|
||||
// domain never sees a DbContext back-reference. Subclassed per-entity-type
|
||||
// solely to make `Set<T>` strongly-typed.
|
||||
// The persistence side of the "after" situation. The (fake) framework stores
|
||||
// its own data-only records — <see cref="ClientData"/> / <see cref="OrderData"/>,
|
||||
// both <see cref="DbBase"/> subclasses — never the domain objects themselves.
|
||||
// Domain state reaches storage only through explicit mapping; the context keeps
|
||||
// a reference-identity map so a second <see cref="Save"/> of the same domain
|
||||
// object refreshes its row instead of duplicating it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Lightweight shadow holder kept alive inside the context.</summary>
|
||||
public abstract class Shadow
|
||||
/// <summary>
|
||||
/// Persisted shape of a <see cref="Client"/>: data only — the orders are
|
||||
/// separate <see cref="OrderData"/> rows, so nothing more is needed here.
|
||||
/// </summary>
|
||||
public sealed class ClientData : DbBase
|
||||
{
|
||||
/// <summary>ID assigned when first persisted.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Back-reference to the owning <see cref="DbContext"/> (internal only).</summary>
|
||||
public DbContext? DbContext { get; internal set; }
|
||||
/// <summary>Display name of the client.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Shadow holder for a persisted <see cref="Client"/>.</summary>
|
||||
public sealed class ClientShadow : Shadow { /* no extra data */ }
|
||||
/// <summary>
|
||||
/// Persisted shape of an <see cref="Order"/>: data only. The object graph is
|
||||
/// flattened for storage — the back-reference to the client becomes a foreign
|
||||
/// key, because storage has ids, not object references.
|
||||
/// </summary>
|
||||
public sealed class OrderData : DbBase
|
||||
{
|
||||
/// <summary>Free-text description of what this order is for.</summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Shadow holder for a persisted <see cref="Order"/>.</summary>
|
||||
public sealed class OrderShadow : Shadow { /* no extra data */ }
|
||||
/// <summary>Order total in cents of the stored order.</summary>
|
||||
public int TotalInCents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the owning client: <see cref="Guid.Empty"/> when the
|
||||
/// order has no client, or when its client had no id yet at save time —
|
||||
/// save clients before their orders.
|
||||
/// </summary>
|
||||
public Guid ClientId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The fake <c>DbContext</c>, refactored to use per-type shadow collections
|
||||
/// (mirroring EF Core's <c>Set<T></c>) plus an instance-level
|
||||
/// <c>Find<T></c> query method.
|
||||
/// The fake <c>DbContext</c>: per-type tables of data records, mirroring EF
|
||||
/// Core's <c>Set<T></c>. <see cref="Save(Client)"/> / <see cref="Save(Order)"/>
|
||||
/// map the domain object's *current* state into its record — inserting on the
|
||||
/// first save, refreshing in place on later ones.
|
||||
/// </summary>
|
||||
public class DbContext
|
||||
{
|
||||
// ── per-type shadow tables ───────────────────────────────────────
|
||||
private readonly List<ClientShadow> _clients = new();
|
||||
private readonly List<OrderShadow> _orders = new();
|
||||
// ── per-type data tables ─────────────────────────────────────────
|
||||
private readonly List<ClientData> _clients = new();
|
||||
private readonly List<OrderData> _orders = new();
|
||||
|
||||
// ── fast lookup: domain object reference → its shadow ────────────
|
||||
private readonly Dictionary<object, Shadow> _byRef = new();
|
||||
// ── fast lookup: domain object reference → its persisted record ──
|
||||
private readonly Dictionary<object, DbBase> _byRef = new();
|
||||
|
||||
// ── exposed per-type tables (read-only view) ────────────────────
|
||||
public IReadOnlyList<ClientShadow> Clients => _clients.AsReadOnly();
|
||||
public IReadOnlyList<OrderShadow> Orders => _orders.AsReadOnly();
|
||||
// ── exposed per-type tables (read-only view) ─────────────────────
|
||||
public IReadOnlyList<ClientData> Clients => _clients.AsReadOnly();
|
||||
public IReadOnlyList<OrderData> Orders => _orders.AsReadOnly();
|
||||
|
||||
/// <summary>Generic accessor: the strongly-typed "table" for <typeparamref name="T"/>.</summary>
|
||||
public IReadOnlyList<T> Set<T>() where T : Shadow
|
||||
public IReadOnlyList<T> Set<T>() where T : DbBase
|
||||
=> typeof(T) switch
|
||||
{
|
||||
var t when t == typeof(ClientShadow) => (IReadOnlyList<T>)_clients.AsReadOnly(),
|
||||
var t when t == typeof(OrderShadow) => (IReadOnlyList<T>)_orders.AsReadOnly(),
|
||||
_ => throw new NotSupportedException($"No per-type table for {typeof(T)}"),
|
||||
var t when t == typeof(ClientData) => (IReadOnlyList<T>)_clients.AsReadOnly(),
|
||||
var t when t == typeof(OrderData) => (IReadOnlyList<T>)_orders.AsReadOnly(),
|
||||
_ => throw new NotSupportedException($"No per-type table for {typeof(T)}"),
|
||||
};
|
||||
|
||||
// ── explicit persistence ────────────────────────────────────────
|
||||
// ── explicit persistence: map + insert, or refresh ───────────────
|
||||
|
||||
/// <summary>Persist a <see cref="Client">:</summary>
|
||||
/// <summary>
|
||||
/// Persist a <see cref="Client"/>: the first save maps it to a fresh
|
||||
/// <see cref="ClientData"/> row and writes the generated id back into the
|
||||
/// domain object; a later save refreshes that row's data in place.
|
||||
/// </summary>
|
||||
public void Save(Client client)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
if (!_byRef.TryAdd(client, new ClientShadow { DbContext = this }))
|
||||
return; // already saved → id unchanged
|
||||
var shadow = (ClientShadow)_byRef[client];
|
||||
var fresh = Mappers.ToClientData(client);
|
||||
if (_byRef.TryGetValue(client, out var existing))
|
||||
{
|
||||
((ClientData)existing).Name = fresh.Name; // refresh, keep row identity
|
||||
return;
|
||||
}
|
||||
var id = Guid.NewGuid();
|
||||
shadow.Id = client.Id = id; // write-back identity
|
||||
fresh.Id = client.Id = id; // write-back identity
|
||||
fresh.DbContext = this;
|
||||
_clients.Add(fresh);
|
||||
_byRef[client] = fresh;
|
||||
}
|
||||
|
||||
/// <summary>Persist an <see cref="Order">.</summary>
|
||||
/// <summary>
|
||||
/// Persist an <see cref="Order"/> (same insert-or-refresh rule as
|
||||
/// <see cref="Save(Client)"/>; save its client first so the foreign key
|
||||
/// can be filled).
|
||||
/// </summary>
|
||||
public void Save(Order order)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(order);
|
||||
if (!_byRef.TryAdd(order, new OrderShadow { DbContext = this }))
|
||||
return; // already saved → id unchanged
|
||||
var shadow = (OrderShadow)_byRef[order];
|
||||
var fresh = Mappers.ToOrderData(order);
|
||||
if (_byRef.TryGetValue(order, out var existing))
|
||||
{
|
||||
var row = (OrderData)existing; // refresh, keep row identity
|
||||
row.Description = fresh.Description;
|
||||
row.TotalInCents = fresh.TotalInCents;
|
||||
row.ClientId = fresh.ClientId;
|
||||
return;
|
||||
}
|
||||
var id = Guid.NewGuid();
|
||||
shadow.Id = order.Id = id; // write-back identity
|
||||
fresh.Id = order.Id = id; // write-back identity
|
||||
fresh.DbContext = this;
|
||||
_orders.Add(fresh);
|
||||
_byRef[order] = fresh;
|
||||
}
|
||||
|
||||
/// <summary>Persist every tracked entity (mirrors EF's <c>SaveChanges</c>).</summary>
|
||||
public int SaveChanges()
|
||||
// ── loading: reassemble domain objects from the stored rows ──────
|
||||
|
||||
/// <summary>
|
||||
/// Materialize the <see cref="Client"/> stored under <paramref name="id"/>,
|
||||
/// including all of its orders (navigation fix-up wires
|
||||
/// <see cref="Order.Client"/> back). Null when nothing is stored under it.
|
||||
/// </summary>
|
||||
public Client? LoadClient(Guid id)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var shadow in _byRef.Values)
|
||||
var row = Find<ClientData>(r => r.Id == id);
|
||||
if (row is null) return null;
|
||||
var client = Mappers.FromClientData(row);
|
||||
foreach (var orderRow in _orders.Where(o => o.ClientId == id))
|
||||
client.Orders.Add(Mappers.FromOrderData(orderRow)); // fix-up wires Order.Client
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialize the <see cref="Order"/> stored under <paramref name="id"/>,
|
||||
/// including its client (holding just this one order), or null when
|
||||
/// nothing is stored under it. Use <see cref="LoadClient"/> to load a
|
||||
/// client with all of its orders.
|
||||
/// </summary>
|
||||
public Order? LoadOrder(Guid id)
|
||||
{
|
||||
var row = Find<OrderData>(r => r.Id == id);
|
||||
if (row is null) return null;
|
||||
var order = Mappers.FromOrderData(row);
|
||||
if (row.ClientId != Guid.Empty)
|
||||
{
|
||||
if (shadow.Id != Guid.Empty) continue; // already had an id
|
||||
shadow.Id = Guid.NewGuid();
|
||||
count++;
|
||||
var clientRow = Find<ClientData>(c => c.Id == row.ClientId);
|
||||
if (clientRow is not null)
|
||||
{
|
||||
var client = Mappers.FromClientData(clientRow);
|
||||
client.Orders.Add(order); // fix-up wires Order.Client
|
||||
}
|
||||
}
|
||||
return count;
|
||||
return order;
|
||||
}
|
||||
|
||||
// ── query helpers ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Search the per-type table for a shadow whose predicate matches.</summary>
|
||||
public T? Find<T>(Func<T, bool> predicate) where T : Shadow
|
||||
/// <summary>Search the per-type table for a record whose predicate matches.</summary>
|
||||
public T? Find<T>(Func<T, bool> predicate) where T : DbBase
|
||||
=> Set<T>().FirstOrDefault(predicate);
|
||||
|
||||
// ── tracking / status ───────────────────────────────────────────
|
||||
@@ -97,6 +166,6 @@ public class DbContext
|
||||
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary>
|
||||
public bool IsTracked(object entity) => _byRef.ContainsKey(entity);
|
||||
|
||||
/// <summary>All shadow entities this context currently manages.</summary>
|
||||
public IReadOnlyList<Shadow> Tracked => _byRef.Values.ToList();
|
||||
/// <summary>All data records this context currently manages.</summary>
|
||||
public IReadOnlyList<DbBase> Tracked => _byRef.Values.ToList();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
+68
-4
@@ -1,13 +1,21 @@
|
||||
namespace After;
|
||||
|
||||
/// <summary>
|
||||
/// Hand-written mappers between the plain domain and the DTOs (no AutoMapper —
|
||||
/// the mapping code is part of the illustration). Each public method maps in
|
||||
/// exactly one direction and never mutates its input.
|
||||
/// Hand-written mappers out of the plain domain (no AutoMapper — the mapping
|
||||
/// code is part of the illustration). Each public method maps in exactly one
|
||||
/// direction and never mutates its input. Two families, two destinations:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ClientDto"/>/<see cref="OrderDto"/> — transport to the
|
||||
/// WebApp;</item>
|
||||
/// <item><see cref="ClientData"/>/<see cref="OrderData"/> — the data-only
|
||||
/// records the persistence framework stores.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The <c>Client.Orders</c> / <c>Order.Client</c> back-reference cycle is
|
||||
/// resolved with a reference-identity cache: the same instance maps to the
|
||||
/// same DTO (and back), so the graph terminates and stays consistent.
|
||||
/// same DTO (and back), so the graph terminates and stays consistent. The
|
||||
/// persistence mapping needs no cache: records reference each other by id,
|
||||
/// not by object, so the graph is flattened and cannot cycle.
|
||||
/// </summary>
|
||||
public static class Mappers
|
||||
{
|
||||
@@ -64,6 +72,7 @@ public static class Mappers
|
||||
{
|
||||
Id = order.Id,
|
||||
Description = order.Description,
|
||||
TotalInCents = order.TotalInCents,
|
||||
ClientDto = order.Client is null ? null : MapClient(order.Client, seen),
|
||||
};
|
||||
seen[order] = dto;
|
||||
@@ -94,9 +103,64 @@ public static class Mappers
|
||||
{
|
||||
Id = dto.Id,
|
||||
Description = dto.Description,
|
||||
TotalInCents = dto.TotalInCents,
|
||||
Client = dto.ClientDto is null ? null : UnmapClient(dto.ClientDto, seen),
|
||||
};
|
||||
seen[dto] = order;
|
||||
return order;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Domain -> persistence (data-only records; graph flattened to ids)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Map a <see cref="Client"/> to its persistence record <see cref="ClientData"/>.</summary>
|
||||
public static ClientData ToClientData(Client client)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
return new ClientData { Id = client.Id, Name = client.Name };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map an <see cref="Order"/> to its persistence record <see cref="OrderData"/>:
|
||||
/// the object graph is flattened — <see cref="Order.Client"/> becomes the
|
||||
/// <see cref="OrderData.ClientId"/> foreign key (empty when the order has
|
||||
/// no client, or when that client has not been saved yet).
|
||||
/// </summary>
|
||||
public static OrderData ToOrderData(Order order)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(order);
|
||||
return new OrderData
|
||||
{
|
||||
Id = order.Id,
|
||||
Description = order.Description,
|
||||
TotalInCents = order.TotalInCents,
|
||||
ClientId = order.Client?.Id ?? Guid.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Persistence -> domain (read-back). A plain field copy for now; when
|
||||
// the domain grows OrderLines, FromOrderData becomes the one-line
|
||||
// reconstruction (row description + total become a single line) —
|
||||
// see EXERCISE2.md.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Map a <see cref="ClientData"/> record back to a <see cref="Client"/>.</summary>
|
||||
public static Client FromClientData(ClientData row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
return new Client { Id = row.Id, Name = row.Name };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map an <see cref="OrderData"/> record back to an <see cref="Order"/>.
|
||||
/// Reassembling the graph (wiring <see cref="Order.Client"/>) is the
|
||||
/// context's job, not the mapper's.
|
||||
/// </summary>
|
||||
public static Order FromOrderData(OrderData row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
return new Order { Id = row.Id, Description = row.Description, TotalInCents = row.TotalInCents };
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,13 @@ public class Order
|
||||
/// <summary>Free-text description of what this order is for.</summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Order total in cents (integer — no floating-point money). A normal
|
||||
/// attribute: it is persisted in <see cref="OrderData"/> and mapped
|
||||
/// one-on-one into <see cref="OrderDto.TotalInCents"/>.
|
||||
/// </summary>
|
||||
public int TotalInCents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The client that owns this order. Set automatically by
|
||||
/// <see cref="ClientOrders"/> when the order is added to
|
||||
|
||||
@@ -13,6 +13,9 @@ public sealed record OrderDto
|
||||
/// <summary>Free-text description of what this order is for.</summary>
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Order total in cents of the underlying order.</summary>
|
||||
public int TotalInCents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Back-reference to the owning client, or <c>null</c> for a bare order
|
||||
/// that has not been added to any client.
|
||||
|
||||
@@ -18,10 +18,10 @@ DbBase.Context = ctx;
|
||||
var jane = new Client { Name = "Jane Doe" };
|
||||
var bob = new Client { Name = "Bob Smith" };
|
||||
|
||||
jane.Orders.Add(new Order { Description = "Design consultation" });
|
||||
jane.Orders.Add(new Order { Description = "Website redesign" });
|
||||
jane.Orders.Add(new Order { Description = "Design consultation", TotalInCents = 120_00 });
|
||||
jane.Orders.Add(new Order { Description = "Website redesign", TotalInCents = 750_00 });
|
||||
|
||||
bob.Orders.Add(new Order { Description = "Monthly retainer" });
|
||||
bob.Orders.Add(new Order { Description = "Monthly retainer", TotalInCents = 85_00 });
|
||||
|
||||
// Orders need to be attached too so they get IDs:
|
||||
ctx.Attach(jane.Orders[0]);
|
||||
@@ -38,7 +38,7 @@ foreach (var client in DbBase.Context!.Tracked.OfType<Client>())
|
||||
{
|
||||
Console.WriteLine($"{client.Name} [{client.Id}]");
|
||||
foreach (var order in client.Orders)
|
||||
Console.WriteLine($" - order [{order.Id}]: {order.Description}");
|
||||
Console.WriteLine($" - order [{order.Id}]: {order.Description} ({order.TotalInCents} cents)");
|
||||
}
|
||||
|
||||
// ----- find by name -------------------------------------------------------
|
||||
|
||||
@@ -11,6 +11,12 @@ public class Order : DbBase
|
||||
/// <summary>Free-text description of what this order is for.</summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Order total in cents (integer — no floating-point money). Part of the
|
||||
/// persisted state, like every other scalar.
|
||||
/// </summary>
|
||||
public int TotalInCents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The client that owns this order. Set automatically by
|
||||
/// <see cref="ClientOrders"/> when the order is added to
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using After;
|
||||
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||
|
||||
namespace BeforeAfter.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The after <see cref="DbContext"/> stores data-only persistence records
|
||||
/// (<see cref="ClientData"/> / <see cref="OrderData"/>), never domain objects:
|
||||
/// <see cref="DbContext"/>'s <c>Save</c> maps domain state into a row —
|
||||
/// inserting on the first save, refreshing in place on later ones — and the
|
||||
/// stored graph is flattened to ids.
|
||||
/// </summary>
|
||||
public class AfterPersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Save_maps_domain_state_into_data_records()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
var clientRow = Assert.Single(db.Clients);
|
||||
Assert.Equal(client.Id, clientRow.Id);
|
||||
Assert.Equal("Acme", clientRow.Name);
|
||||
|
||||
var orderRow = Assert.Single(db.Orders);
|
||||
Assert.Equal(order.Id, orderRow.Id);
|
||||
Assert.Equal("one", orderRow.Description);
|
||||
Assert.Equal(150_00, orderRow.TotalInCents);
|
||||
Assert.Equal(client.Id, orderRow.ClientId); // the graph is flattened to a key
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_order_without_client_stores_an_empty_client_id()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("orphan").Build();
|
||||
|
||||
db.Save(order);
|
||||
|
||||
Assert.Equal(Guid.Empty, Assert.Single(db.Orders).ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Saving_again_refreshes_the_row_instead_of_duplicating_it()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
order.Description = "one (edited)";
|
||||
order.TotalInCents = 99_00;
|
||||
client.Name = "Acme GmbH";
|
||||
db.Save(order);
|
||||
db.Save(client);
|
||||
|
||||
// One row per saved object, refreshed with the *current* state:
|
||||
var orderRow = Assert.Single(db.Orders);
|
||||
Assert.Equal(order.Id, orderRow.Id);
|
||||
Assert.Equal("one (edited)", orderRow.Description);
|
||||
Assert.Equal(99_00, orderRow.TotalInCents);
|
||||
Assert.Equal(client.Id, orderRow.ClientId);
|
||||
|
||||
var clientRow = Assert.Single(db.Clients);
|
||||
Assert.Equal("Acme GmbH", clientRow.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rows_are_reachable_through_Set_and_Find()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
Assert.Single(db.Set<ClientData>());
|
||||
Assert.Single(db.Set<OrderData>());
|
||||
|
||||
var found = db.Find<OrderData>(row => row.Description == "one");
|
||||
Assert.Equal(order.Id, found!.Id);
|
||||
Assert.Null(db.Find<OrderData>(row => row.Description == "no such order"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Read-back: LoadOrder / LoadClient reassemble domain objects from the
|
||||
// stored rows (identity round-trip for now — OrderLines come later).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Loading_an_order_restores_its_state_and_its_client()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
var loaded = db.LoadOrder(order.Id);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(order.Id, loaded!.Id);
|
||||
Assert.Equal("one", loaded.Description);
|
||||
Assert.Equal(150_00, loaded.TotalInCents);
|
||||
|
||||
// The graph is reassembled: the loaded order knows its client again,
|
||||
// and the client's collection holds the loaded order.
|
||||
Assert.NotNull(loaded.Client);
|
||||
Assert.Equal("Acme", loaded.Client!.Name);
|
||||
Assert.Same(loaded, Assert.Single(loaded.Client.Orders));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loading_a_client_restores_it_with_all_of_its_orders()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var first = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||
var second = ValidOrder().WithDescription("two").WithTotal(250_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(first).WithOrder(second).Build();
|
||||
db.Save(client);
|
||||
db.Save(first);
|
||||
db.Save(second);
|
||||
|
||||
var loaded = db.LoadClient(client.Id);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Acme", loaded!.Name);
|
||||
Assert.Equal(2, loaded.Orders.Count);
|
||||
Assert.All(loaded.Orders, o => Assert.Same(loaded, o.Client));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loading_an_unknown_id_returns_null()
|
||||
{
|
||||
var db = new DbContext();
|
||||
|
||||
Assert.Null(db.LoadOrder(Guid.NewGuid()));
|
||||
Assert.Null(db.LoadClient(Guid.NewGuid()));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using After;
|
||||
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||
using System.Reflection;
|
||||
|
||||
namespace BeforeAfter.Tests;
|
||||
@@ -7,6 +9,11 @@ namespace BeforeAfter.Tests;
|
||||
/// Tests for the AFTER situation (Yak 02): plain domain objects (no
|
||||
/// <see cref="DbBase"/>), DTOs (records) carrying the data including the
|
||||
/// relations, hand-written mappers, and a WebApp that consumes only DTOs.
|
||||
///
|
||||
/// Domain objects in these tests are constructed through the TestDataBuilders
|
||||
/// (<see cref="OrderBuilder.ValidOrder"/> / <see cref="ClientBuilder.ValidClient"/>)
|
||||
/// so the tests survive domain evolution; DTO initializers stay literal —
|
||||
/// DTOs are the transport shapes under test.
|
||||
/// </summary>
|
||||
public class AfterTests
|
||||
{
|
||||
@@ -42,7 +49,7 @@ public class AfterTests
|
||||
public void ToClientDto_maps_all_scalar_fields()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var client = new Client { Id = id, Name = "Acme" };
|
||||
var client = ValidClient().WithId(id).WithName("Acme").Build();
|
||||
|
||||
var dto = Mappers.ToClientDto(client);
|
||||
|
||||
@@ -54,12 +61,13 @@ public class AfterTests
|
||||
public void ToOrderDto_maps_all_scalar_fields()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var order = new Order { Id = id, Description = "one" };
|
||||
var order = ValidOrder().WithId(id).WithDescription("one").WithTotal(150_00).Build();
|
||||
|
||||
var dto = Mappers.ToOrderDto(order);
|
||||
|
||||
Assert.Equal(id, dto.Id);
|
||||
Assert.Equal("one", dto.Description);
|
||||
Assert.Equal(150_00, dto.TotalInCents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -78,12 +86,13 @@ public class AfterTests
|
||||
public void FromOrderDto_maps_all_scalar_fields()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var dto = new OrderDto { Id = id, Description = "one" };
|
||||
var dto = new OrderDto { Id = id, Description = "one", TotalInCents = 150_00 };
|
||||
|
||||
var order = Mappers.FromOrderDto(dto);
|
||||
|
||||
Assert.Equal(id, order.Id);
|
||||
Assert.Equal("one", order.Description);
|
||||
Assert.Equal(150_00, order.TotalInCents);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -93,9 +102,8 @@ public class AfterTests
|
||||
[Fact]
|
||||
public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent()
|
||||
{
|
||||
var client = new Client { Name = "Acme" };
|
||||
var order = new Order { Description = "one" };
|
||||
client.Orders.Add(order);
|
||||
var order = ValidOrder().WithDescription("one").Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
|
||||
var dto = Mappers.ToClientDto(client);
|
||||
var orderDto = Assert.Single(dto.Orders);
|
||||
@@ -128,9 +136,8 @@ public class AfterTests
|
||||
[Fact]
|
||||
public void Domain_round_trip_preserves_all_state()
|
||||
{
|
||||
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
||||
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
|
||||
client.Orders.Add(order);
|
||||
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||
|
||||
var roundTrip = Mappers.FromClientDto(Mappers.ToClientDto(client));
|
||||
|
||||
@@ -139,6 +146,7 @@ public class AfterTests
|
||||
var roundTripOrder = Assert.Single(roundTrip.Orders);
|
||||
Assert.Equal(order.Id, roundTripOrder.Id);
|
||||
Assert.Equal(order.Description, roundTripOrder.Description);
|
||||
Assert.Equal(order.TotalInCents, roundTripOrder.TotalInCents);
|
||||
// The back-reference is still consistent on the reconstructed graph:
|
||||
Assert.Same(roundTrip, roundTripOrder.Client);
|
||||
}
|
||||
@@ -146,9 +154,8 @@ public class AfterTests
|
||||
[Fact]
|
||||
public void Dto_round_trip_preserves_all_state()
|
||||
{
|
||||
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
||||
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
|
||||
client.Orders.Add(order);
|
||||
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||
var dto = Mappers.ToClientDto(client);
|
||||
var dtoOrder = dto.Orders[0];
|
||||
|
||||
@@ -159,6 +166,7 @@ public class AfterTests
|
||||
var roundTripOrder = Assert.Single(roundTrip.Orders);
|
||||
Assert.Equal(dtoOrder.Id, roundTripOrder.Id);
|
||||
Assert.Equal(dtoOrder.Description, roundTripOrder.Description);
|
||||
Assert.Equal(dtoOrder.TotalInCents, roundTripOrder.TotalInCents);
|
||||
Assert.Same(roundTrip, roundTripOrder.ClientDto);
|
||||
}
|
||||
|
||||
@@ -168,9 +176,8 @@ public class AfterTests
|
||||
[Fact]
|
||||
public void ToClientDto_does_not_mutate_the_domain()
|
||||
{
|
||||
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
||||
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
|
||||
client.Orders.Add(order);
|
||||
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").Build();
|
||||
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||
var ordersCount = client.Orders.Count;
|
||||
|
||||
Mappers.ToClientDto(client);
|
||||
@@ -243,9 +250,8 @@ public class AfterTests
|
||||
public void After_WebApp_consumes_dtos()
|
||||
{
|
||||
var app = new WebApp();
|
||||
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
||||
var order = new Order { Id = Guid.NewGuid(), Description = "order one" };
|
||||
client.Orders.Add(order);
|
||||
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("order one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||
|
||||
var view = app.ShowClient(Mappers.ToClientDto(client));
|
||||
|
||||
@@ -262,9 +268,8 @@ public class AfterTests
|
||||
public void DbContext_saves_plain_domain_objects_and_tracks_them()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var client = new Client { Name = "Acme" };
|
||||
var order = new Order { Description = "one" };
|
||||
client.Orders.Add(order);
|
||||
var order = ValidOrder().WithDescription("one").Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
|
||||
Assert.Equal(Guid.Empty, client.Id);
|
||||
db.Save(client);
|
||||
@@ -282,7 +287,7 @@ public class AfterTests
|
||||
public void Saving_twice_does_not_reassign_the_id()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var client = new Client { Name = "Acme" };
|
||||
var client = ValidClient().WithName("Acme").Build();
|
||||
db.Save(client);
|
||||
var id = client.Id;
|
||||
|
||||
@@ -296,7 +301,7 @@ public class AfterTests
|
||||
public void A_saved_domain_object_keeps_no_reference_to_the_context()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var client = new Client { Name = "Acme" };
|
||||
var client = ValidClient().WithName("Acme").Build();
|
||||
db.Save(client);
|
||||
|
||||
// Saved (the id was assigned by the context) ...
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using After;
|
||||
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||
|
||||
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 = ValidOrder().WithTotal(50_00).Build();
|
||||
|
||||
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 = ValidOrder().WithTotal(100_00).Build();
|
||||
|
||||
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 = ValidOrder().WithTotal(150_00).Build();
|
||||
|
||||
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 = ValidOrder().WithTotal(150_00).Build();
|
||||
|
||||
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 = ValidOrder().WithTotal(150_00).Build();
|
||||
|
||||
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 = ValidOrder().WithTotal(totalInCents).Build();
|
||||
|
||||
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 = ValidOrder().WithId(id).WithTotal(150_00).Build();
|
||||
|
||||
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 = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||
|
||||
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 = ValidOrder().WithTotal(totalInCents).Build();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using After;
|
||||
|
||||
namespace BeforeAfter.Tests.TestData;
|
||||
|
||||
/// <summary>
|
||||
/// Test data builder for a <see cref="Client"/> with its orders
|
||||
/// (TestDataBuilder pattern). <see cref="ValidClient"/> starts from a
|
||||
/// <em>fresh</em> client, exactly what <c>new Client()</c> gives.
|
||||
/// </summary>
|
||||
public sealed class ClientBuilder
|
||||
{
|
||||
private readonly Client _client = new();
|
||||
|
||||
/// <summary>A valid, fresh client — the neutral starting point for tests.</summary>
|
||||
public static ClientBuilder ValidClient() => new();
|
||||
|
||||
public ClientBuilder WithId(Guid id)
|
||||
{
|
||||
_client.Id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ClientBuilder WithName(string name)
|
||||
{
|
||||
_client.Name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Adds an order to the client (navigation fix-up wires the back-reference).</summary>
|
||||
public ClientBuilder WithOrder(Order order)
|
||||
{
|
||||
_client.Orders.Add(order);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Builds the given builder and adds its order — chaining sugar for <c>.WithOrder(builder.Build())</c>.</summary>
|
||||
public ClientBuilder WithOrder(OrderBuilder order) => WithOrder(order.Build());
|
||||
|
||||
public Client Build() => _client;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using After;
|
||||
|
||||
namespace BeforeAfter.Tests.TestData;
|
||||
|
||||
/// <summary>
|
||||
/// Test data builder for an <see cref="Order"/> (TestDataBuilder pattern).
|
||||
/// <see cref="ValidOrder"/> starts from a <em>fresh</em> order —
|
||||
/// <see cref="Guid.Empty"/> id, empty description, zero total, exactly what
|
||||
/// <c>new Order()</c> gives — and every <c>With...</c> returns the builder for
|
||||
/// chaining; <see cref="Build"/> hands back the order.
|
||||
///
|
||||
/// This class is the only place tests touch an order's *construction*: when
|
||||
/// the domain evolves (e.g. OrderLines making the total computed), only these
|
||||
/// internals change and the tests keep reading the same.
|
||||
/// </summary>
|
||||
public sealed class OrderBuilder
|
||||
{
|
||||
private readonly Order _order = new();
|
||||
|
||||
/// <summary>A valid, fresh order — the neutral starting point for tests.</summary>
|
||||
public static OrderBuilder ValidOrder() => new();
|
||||
|
||||
public OrderBuilder WithId(Guid id)
|
||||
{
|
||||
_order.Id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public OrderBuilder WithDescription(string description)
|
||||
{
|
||||
_order.Description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the total in cents (<c>150_00</c> = €150.00). How the requested
|
||||
/// total is realised inside the order is an implementation detail that may
|
||||
/// change as the domain evolves; this API stays.
|
||||
/// </summary>
|
||||
public OrderBuilder WithTotal(int totalInCents)
|
||||
{
|
||||
_order.TotalInCents = totalInCents;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Order Build() => _order;
|
||||
}
|
||||
Reference in New Issue
Block a user