Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89a1158c93 | ||
|
|
08b52806de | ||
|
|
53586d33a4 | ||
|
|
37ebb21182 | ||
|
|
d39426d328 | ||
|
|
803580786b | ||
|
|
49f4ca395f | ||
|
|
9895ef87e4 | ||
|
|
01ba13f94d | ||
|
|
08a6fa757a | ||
|
|
ec9586c316 | ||
|
|
4b6e6f5a00 | ||
|
|
325925a592 | ||
|
|
68b9657d62 | ||
|
|
58f7a1184a | ||
|
|
06a12e55f8 | ||
|
|
8b46fccf08 | ||
|
|
c4bfc14f32 | ||
|
|
c21c6416cf | ||
|
|
92b9e3f784 | ||
|
|
5270eabcf3 | ||
|
|
804695f12d | ||
|
|
1188ce4e57 | ||
|
|
e15962d86a | ||
|
|
cdadafe676 | ||
|
|
49a5633a7d | ||
|
|
5d02c1aabf | ||
|
|
7f0981e012 | ||
|
|
b012d0aaa0 | ||
|
|
406e8a97f6 | ||
|
|
bd4afcc935 | ||
|
|
c22a1e2ffa | ||
|
|
463245288a | ||
|
|
7e38f67e0b | ||
|
|
42b3c502a1 | ||
|
|
9d0079cbf0 |
@@ -3,8 +3,6 @@ artifacts/
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
.dotnet-cli/
|
.dotnet-cli/
|
||||||
.npm-cache/
|
|
||||||
node_modules/
|
|
||||||
*.user
|
*.user
|
||||||
# NuGet restore scratch dir (DOTNET_CLI_HOME fallout)
|
# NuGet restore scratch dir (DOTNET_CLI_HOME fallout)
|
||||||
.local/
|
.local/
|
||||||
|
|||||||
@@ -2,33 +2,20 @@
|
|||||||
|
|
||||||
Exercise repo: illustrating the transition from DB-backed domain classes
|
Exercise repo: illustrating the transition from DB-backed domain classes
|
||||||
(`DbBase` inheritance) to plain domain objects + DTOs + mappers.
|
(`DbBase` inheritance) to plain domain objects + DTOs + mappers.
|
||||||
|
Diagrams: `before-after-dto.mmd`, `after-dto.mmd` (source sketch:
|
||||||
**Working Set (for now): the split projects — `src/Domain`,
|
`before-after-dto-sketch.png`).
|
||||||
`src/Db`, `src/Web`** — focus changes there; leave other
|
|
||||||
projects untouched unless a yak explicitly says otherwise.
|
|
||||||
|
|
||||||
## Rendering diagrams (mermaid → svg)
|
## Rendering diagrams (mermaid → svg)
|
||||||
|
|
||||||
Mermaid diagrams are rendered to SVG with mermaid-cli (`mmdc`), pinned as a
|
Mermaid diagrams can be rendered to SVG with the `mmdc` CLI (mermaid-cli,
|
||||||
devDependency in `package.json`. Install it once per clone:
|
installed via npm): `mmdc -i <input>` writes `<input>.svg` next to the
|
||||||
|
source, i.e. `<name>.mmd` becomes `<name>.mmd.svg`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install # installs @mermaid-js/mermaid-cli into node_modules/
|
mmdc -i before-after-dto.mmd # -> before-after-dto.mmd.svg
|
||||||
|
mmdc -i after-dto.mmd # -> after-dto.mmd.svg
|
||||||
```
|
```
|
||||||
|
|
||||||
Then render with `npx mmdc`, which resolves the local install — the same
|
|
||||||
command on Linux, macOS and **Windows** (PowerShell/cmd):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npx mmdc -p puppeteer.config.json -i <diagram>.mmd # -> <diagram>.mmd.svg
|
|
||||||
```
|
|
||||||
|
|
||||||
`-p puppeteer.config.json` passes the headless-Chrome flags (`--no-sandbox`
|
|
||||||
et al.) — without it mmdc fails to launch the browser in agent/sandbox
|
|
||||||
shells. On desktop shells it is not needed: plain `npx mmdc -i <input>`
|
|
||||||
works. On first run puppeteer downloads its own Chrome. `node_modules/` is
|
|
||||||
gitignored.
|
|
||||||
|
|
||||||
## Running dotnet
|
## Running dotnet
|
||||||
|
|
||||||
dotnet SDK 10 is installed **via mise** (see `mise.toml`). It is NOT on PATH in
|
dotnet SDK 10 is installed **via mise** (see `mise.toml`). It is NOT on PATH in
|
||||||
@@ -39,6 +26,16 @@ agent/sandbox shells, and SDK 10 additionally fails without
|
|||||||
scripts/dotnet.sh build db-subclass-to-dto.sln
|
scripts/dotnet.sh build db-subclass-to-dto.sln
|
||||||
scripts/dotnet.sh test db-subclass-to-dto.sln
|
scripts/dotnet.sh test db-subclass-to-dto.sln
|
||||||
scripts/run-tests.sh # full test suite, from repo root
|
scripts/run-tests.sh # full test suite, from repo root
|
||||||
|
scripts/run-console.sh # run the Before.Console demo (bash; delegates
|
||||||
|
# to dotnet.sh); app args go after `--`
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows/PowerShell counterpart:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scripts\run-console.ps1 # run the Before.Console demo; app args go
|
||||||
|
# directly (the script inserts `--` for
|
||||||
|
# dotnet run); needs no DOTNET_CLI_HOME setup
|
||||||
```
|
```
|
||||||
|
|
||||||
The wrappers cd to the repo root (the test runner must run from the parent
|
The wrappers cd to the repo root (the test runner must run from the parent
|
||||||
@@ -73,9 +70,7 @@ yx done "<yak name>" # set state to done (only after acceptance
|
|||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Target framework: net10.0 (SDK pinned by mise).
|
- Target framework: net10.0 (SDK pinned by mise).
|
||||||
- Tests: xUnit, mirroring the src split: `tests/Domain.Tests`,
|
- Tests: xUnit, one `tests/BeforeAfter.Tests/` project.
|
||||||
`tests/Db.Tests`, `tests/Web.Tests`, with the shared test-data builders in
|
|
||||||
`tests/TestData`.
|
|
||||||
- No EF Core / SQLite / AutoMapper — the DB layer is a small hand-rolled fake
|
- No EF Core / SQLite / AutoMapper — the DB layer is a small hand-rolled fake
|
||||||
(pure managed code, cross-platform).
|
(pure managed code, cross-platform).
|
||||||
- Commit per yak with a message referencing the yak.
|
- Commit per yak with a message referencing the yak.
|
||||||
+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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
%% After: Client and Order become plain domain objects (no DbBase inheritance).
|
||||||
|
%% Dedicated *Dto classes carry data to/from the WebApp, and a mapper converts
|
||||||
|
%% each way, including the relations. EF dependency isolated to persistence layer.
|
||||||
|
classDiagram
|
||||||
|
class DbContext {
|
||||||
|
+SaveChangesAsync()
|
||||||
|
}
|
||||||
|
class DbBase {
|
||||||
|
+Guid id
|
||||||
|
}
|
||||||
|
class WebApp {
|
||||||
|
<<Web App>>
|
||||||
|
}
|
||||||
|
class Client {
|
||||||
|
+Guid id
|
||||||
|
+string name
|
||||||
|
}
|
||||||
|
class Order {
|
||||||
|
+Guid id
|
||||||
|
}
|
||||||
|
class ClientDto {
|
||||||
|
+Guid id
|
||||||
|
+string name
|
||||||
|
}
|
||||||
|
class OrderDto {
|
||||||
|
+Guid id
|
||||||
|
+ClientDto client
|
||||||
|
}
|
||||||
|
class Mappers {
|
||||||
|
+ClientDto ToClientDto(Client c)
|
||||||
|
+Client FromClientDto(ClientDto dto)
|
||||||
|
+OrderDto ToOrderDto(Order o)
|
||||||
|
+Order FromOrderDto(OrderDto dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
DbContext ..> DbBase : still used by persistence layer
|
||||||
|
WebApp --> ClientDto
|
||||||
|
WebApp --> OrderDto
|
||||||
|
WebApp --> Mappers
|
||||||
|
Mappers <--> Client : maps each way
|
||||||
|
Mappers <--> ClientDto : maps each way
|
||||||
|
Mappers <--> Order : maps each way
|
||||||
|
Mappers <--> OrderDto : maps each way
|
||||||
|
Client "1" --> "*" Order : has many (domain relation)
|
||||||
|
ClientDto "1" --> "*" OrderDto : has many (DTO relation)
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
%% Before: WebApp's domain classes inherit from DbBase (EF),
|
||||||
|
%% which depends on DbContext. Relations live inside the DB-backed classes.
|
||||||
|
classDiagram
|
||||||
|
class DbContext {
|
||||||
|
+SaveChangesAsync()
|
||||||
|
}
|
||||||
|
class DbBase {
|
||||||
|
+Guid id
|
||||||
|
}
|
||||||
|
class WebApp {
|
||||||
|
<<Web App>>
|
||||||
|
}
|
||||||
|
class Client {
|
||||||
|
+Guid id
|
||||||
|
+string name
|
||||||
|
}
|
||||||
|
class Order {
|
||||||
|
+Guid id
|
||||||
|
}
|
||||||
|
|
||||||
|
DbBase --> DbContext : uses (EF)
|
||||||
|
WebApp ..> DbBase : depends on
|
||||||
|
Client --|> DbBase : inherits
|
||||||
|
Order --|> DbBase : inherits
|
||||||
|
WebApp --> Client
|
||||||
|
WebApp --> Order
|
||||||
|
Client "1" --> "*" Order : has many
|
||||||
|
Order "*" --> "1" Client : open / close
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
+72
-100
@@ -1,25 +1,23 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.0.31903.59
|
VisualStudioVersion = 17.0.31903.59
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "src\Domain\Domain.csproj", "{A103FFE5-4F20-4DE1-A086-38012E701F9E}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Before", "src\Before\Before.csproj", "{2FF256B4-B085-449E-95AD-B9E6202DA94A}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Db", "src\Db\Db.csproj", "{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "After", "src\After\After.csproj", "{E65C0410-A863-44B0-88A4-6C74EF929419}"
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web", "src\Web\Web.csproj", "{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}"
|
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestData", "tests\TestData\TestData.csproj", "{A9EA20AD-2B2D-456B-858A-1575C4B99E11}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeforeAfter.Tests", "tests\BeforeAfter.Tests\BeforeAfter.Tests.csproj", "{F5E0C182-CBC3-4440-AF8F-32887F9B589C}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain.Tests", "tests\Domain.Tests\Domain.Tests.csproj", "{824867CB-A83B-4968-A29D-D39FC17BCB85}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Db.Tests", "tests\Db.Tests\Db.Tests.csproj", "{7DA9971D-306A-4997-A585-D956F41C7A08}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExtractMethod", "tools\ExtractMethod\ExtractMethod.csproj", "{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web.Tests", "tests\Web.Tests\Web.Tests.csproj", "{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Before.Console", "src\Before.Console\Before.Console.csproj", "{AC39E693-E83D-4B50-A120-0DE259108AF2}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@@ -31,101 +29,75 @@ Global
|
|||||||
Release|x86 = Release|x86
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x64.Build.0 = Debug|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x86.Build.0 = Debug|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|Any CPU.Build.0 = Release|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x64.ActiveCfg = Release|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x64.Build.0 = Release|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x86.ActiveCfg = Release|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x86.Build.0 = Release|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x64.Build.0 = Debug|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x86.Build.0 = Debug|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|Any CPU.Build.0 = Release|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x64.ActiveCfg = Release|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x64.Build.0 = Release|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x86.ActiveCfg = Release|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x86.Build.0 = Release|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x64.Build.0 = Debug|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x86.Build.0 = Debug|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|Any CPU.Build.0 = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x64.ActiveCfg = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x64.Build.0 = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x86.ActiveCfg = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x86.Build.0 = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x64.Build.0 = Debug|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x86.Build.0 = Debug|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|Any CPU.Build.0 = Release|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x64.ActiveCfg = Release|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x64.Build.0 = Release|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x86.ActiveCfg = Release|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x86.Build.0 = Release|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x64.Build.0 = Debug|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x86.Build.0 = Debug|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|Any CPU.Build.0 = Release|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x64.ActiveCfg = Release|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x64.Build.0 = Release|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x86.ActiveCfg = Release|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x86.Build.0 = Release|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{E65C0410-A863-44B0-88A4-6C74EF929419} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{AC39E693-E83D-4B50-A120-0DE259108AF2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
Generated
-2521
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"devDependencies": {
|
|
||||||
"@mermaid-js/mermaid-cli": "^11.17.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Run the Before.Console demo app (src/Before.Console/Program.cs) on Windows.
|
||||||
|
#
|
||||||
|
# Windows counterpart of scripts/run-console.sh:
|
||||||
|
# - runs from the repo root, like the other scripts
|
||||||
|
# - prefers mise (see mise.toml) and falls back to a dotnet on PATH
|
||||||
|
#
|
||||||
|
# Troubleshooting: if dotnet fails with "The user's home directory could not
|
||||||
|
# be determined" (some sandboxed shells), set DOTNET_CLI_HOME first:
|
||||||
|
# PS> $env:DOTNET_CLI_HOME = "$PWD\.dotnet-cli"
|
||||||
|
#
|
||||||
|
# Usage (from anywhere):
|
||||||
|
# PS> .\scripts\run-console.ps1 # no args
|
||||||
|
# PS> .\scripts\run-console.ps1 --verbose # args go to the program
|
||||||
|
# The script inserts the `--` separator for dotnet run itself.
|
||||||
|
#
|
||||||
|
# If script execution is blocked by policy:
|
||||||
|
# PS> powershell -ExecutionPolicy Bypass -File .\scripts\run-console.ps1
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
Push-Location $repoRoot
|
||||||
|
try {
|
||||||
|
$dotnetArgs = @('run', '--project', 'src/Before.Console')
|
||||||
|
if ($args.Count -gt 0) {
|
||||||
|
$dotnetArgs += '--'
|
||||||
|
$dotnetArgs += $args
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Get-Command mise -ErrorAction SilentlyContinue) {
|
||||||
|
mise exec -- dotnet @dotnetArgs
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
dotnet @dotnetArgs
|
||||||
|
}
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the Before.Console demo app (src/Before.Console/Program.cs).
|
||||||
|
# Delegates to scripts/dotnet.sh — see there for why the wrapper exists
|
||||||
|
# (mise-installed dotnet, DOTNET_CLI_HOME, run from repo root).
|
||||||
|
#
|
||||||
|
# Usage: scripts/run-console.sh [app args]
|
||||||
|
# scripts/run-console.sh # no args
|
||||||
|
# scripts/run-console.sh -- --verbose # args are passed to the program
|
||||||
|
set -euo pipefail
|
||||||
|
exec "$(cd "$(dirname "$0")" && pwd)/dotnet.sh" run --project src/Before.Console -- "$@"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace After;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A plain domain object — the "after" shape of the exercise: no
|
||||||
|
/// <see cref="DbBase"/> inheritance, no knowledge of <see cref="DbContext"/>.
|
||||||
|
/// Its data travels to the WebApp as a <see cref="ClientDto"/>; persisting it
|
||||||
|
/// is an explicit act of the context, not an inherited capability.
|
||||||
|
/// </summary>
|
||||||
|
public class Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key as a plain value: fresh objects have
|
||||||
|
/// <see cref="Guid.Empty"/> until a <see cref="DbContext"/> explicitly
|
||||||
|
/// saves them (which copies a generated id in). A plain field — it is not
|
||||||
|
/// a persistence concept leaking into the domain.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Display name of the client.</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Navigation collection of this client's orders, with EF-style
|
||||||
|
/// navigation fix-up (see <see cref="ClientOrders"/>).
|
||||||
|
/// </summary>
|
||||||
|
public ClientOrders Orders { get; }
|
||||||
|
|
||||||
|
public Client()
|
||||||
|
{
|
||||||
|
Orders = new ClientOrders(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An <see cref="Order"/> collection that fakes EF's navigation fix-up: when an
|
||||||
|
/// order is added, its <see cref="Order.Client"/> back-reference is set to the
|
||||||
|
/// owning client, exactly as EF would wire up the two ends of the relation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClientOrders : Collection<Order>
|
||||||
|
{
|
||||||
|
private readonly Client _owner;
|
||||||
|
|
||||||
|
public ClientOrders(Client owner)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both Add(...) and Insert(...) funnel through InsertItem, so overriding
|
||||||
|
// it covers every way an order can be added to the collection.
|
||||||
|
protected override void InsertItem(int index, Order item)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
item.Client = _owner; // navigation fix-up: order now knows its client
|
||||||
|
base.InsertItem(index, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace After;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO carrying a <see cref="Client"/> — including its relation — to the
|
||||||
|
/// WebApp. A record: value equality keeps round-trip comparisons trivial.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ClientDto
|
||||||
|
{
|
||||||
|
/// <summary>Primary key of the underlying client.</summary>
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Display name of the client.</summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The client's orders, each carrying a back-reference to this DTO (the
|
||||||
|
/// relation, mapped both ways). Settable — not just <c>init</c> — so
|
||||||
|
/// <see cref="Mappers"/> can fill it in a second pass once the
|
||||||
|
/// client/order cycle has been resolved by the reference cache.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<OrderDto> Orders { get; set; } = Array.Empty<OrderDto>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace After;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key. Fresh (unsaved) entities have <see cref="Guid.Empty"/>;
|
||||||
|
/// <see cref="DbContext"/> assigns a real id when it saves them, faking
|
||||||
|
/// EF's identity generation.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Back-reference to the owning <see cref="DbContext"/>. In the after
|
||||||
|
/// 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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
namespace After;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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>
|
||||||
|
/// 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>Display name of the client.</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>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>: 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 data tables ─────────────────────────────────────────
|
||||||
|
private readonly List<ClientData> _clients = new();
|
||||||
|
private readonly List<OrderData> _orders = 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<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 : DbBase
|
||||||
|
=> typeof(T) switch
|
||||||
|
{
|
||||||
|
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: map + insert, or refresh ───────────────
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
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();
|
||||||
|
fresh.Id = client.Id = id; // write-back identity
|
||||||
|
fresh.DbContext = this;
|
||||||
|
_clients.Add(fresh);
|
||||||
|
_byRef[client] = fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
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();
|
||||||
|
fresh.Id = order.Id = id; // write-back identity
|
||||||
|
fresh.DbContext = this;
|
||||||
|
_orders.Add(fresh);
|
||||||
|
_byRef[order] = fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 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)
|
||||||
|
{
|
||||||
|
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 order;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── query helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <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 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary>
|
||||||
|
public bool IsTracked(object entity) => _byRef.ContainsKey(entity);
|
||||||
|
|
||||||
|
/// <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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
namespace After;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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. 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
|
||||||
|
{
|
||||||
|
/// <summary>Map a <see cref="Client"/> (with its orders) to a <see cref="ClientDto"/>.</summary>
|
||||||
|
public static ClientDto ToClientDto(Client client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
return MapClient(client, new Dictionary<object, object>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Map an <see cref="Order"/> (with its client back-reference) to an <see cref="OrderDto"/>.</summary>
|
||||||
|
public static OrderDto ToOrderDto(Order order)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(order);
|
||||||
|
return MapOrder(order, new Dictionary<object, object>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Map a <see cref="ClientDto"/> (with its orders) back to a <see cref="Client"/>.</summary>
|
||||||
|
public static Client FromClientDto(ClientDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
return UnmapClient(dto, new Dictionary<object, object>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Map an <see cref="OrderDto"/> (with its client back-reference) back to an <see cref="Order"/>.</summary>
|
||||||
|
public static Order FromOrderDto(OrderDto dto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dto);
|
||||||
|
return UnmapOrder(dto, new Dictionary<object, object>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Domain -> DTO
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
private static ClientDto MapClient(Client client, Dictionary<object, object> seen)
|
||||||
|
{
|
||||||
|
if (seen.TryGetValue(client, out var cached))
|
||||||
|
return (ClientDto)cached;
|
||||||
|
|
||||||
|
var dto = new ClientDto { Id = client.Id, Name = client.Name };
|
||||||
|
seen[client] = dto; // cache first: breaks the cycle
|
||||||
|
dto.Orders = client.Orders
|
||||||
|
.Select(o => MapOrder(o, seen))
|
||||||
|
.ToList(); // fill second, through the cache
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OrderDto MapOrder(Order order, Dictionary<object, object> seen)
|
||||||
|
{
|
||||||
|
if (seen.TryGetValue(order, out var cached))
|
||||||
|
return (OrderDto)cached;
|
||||||
|
|
||||||
|
var dto = new OrderDto
|
||||||
|
{
|
||||||
|
Id = order.Id,
|
||||||
|
Description = order.Description,
|
||||||
|
TotalInCents = order.TotalInCents,
|
||||||
|
ClientDto = order.Client is null ? null : MapClient(order.Client, seen),
|
||||||
|
};
|
||||||
|
seen[order] = dto;
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// DTO -> domain
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
private static Client UnmapClient(ClientDto dto, Dictionary<object, object> seen)
|
||||||
|
{
|
||||||
|
if (seen.TryGetValue(dto, out var cached))
|
||||||
|
return (Client)cached;
|
||||||
|
|
||||||
|
var client = new Client { Id = dto.Id, Name = dto.Name };
|
||||||
|
seen[dto] = client; // cache first: breaks the cycle
|
||||||
|
foreach (var orderDto in dto.Orders)
|
||||||
|
client.Orders.Add(UnmapOrder(orderDto, seen)); // fix-up wires order.Client
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Order UnmapOrder(OrderDto dto, Dictionary<object, object> seen)
|
||||||
|
{
|
||||||
|
if (seen.TryGetValue(dto, out var cached))
|
||||||
|
return (Order)cached;
|
||||||
|
|
||||||
|
var order = new Order
|
||||||
|
{
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
namespace After;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An order as a plain domain object: no <see cref="DbBase"/> inheritance, no
|
||||||
|
/// knowledge of <see cref="DbContext"/>. Its data travels to the WebApp as an
|
||||||
|
/// <see cref="OrderDto"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class Order
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key as a plain value: <see cref="Guid.Empty"/> until a
|
||||||
|
/// <see cref="DbContext"/> explicitly saves this order (which copies a
|
||||||
|
/// generated id in).
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <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 discounted total in cents, copied onto the order by the caller of
|
||||||
|
/// the discount flow (see <see cref="DiscountCalculator"/>). Derived
|
||||||
|
/// data: it lives in the domain only — neither <see cref="OrderDto"/>
|
||||||
|
/// nor <see cref="OrderData"/> carries it — and the applied percentage
|
||||||
|
/// is not stored either, being derivable from the two totals.
|
||||||
|
/// </summary>
|
||||||
|
public int DiscountedTotalInCents { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The client that owns this order. Set automatically by
|
||||||
|
/// <see cref="ClientOrders"/> when the order is added to
|
||||||
|
/// <see cref="Client.Orders"/> (navigation fix-up).
|
||||||
|
/// </summary>
|
||||||
|
public Client? Client { get; set; }
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
using Domain;
|
namespace After;
|
||||||
|
|
||||||
namespace Web;
|
/// <summary>
|
||||||
|
/// DTO carrying an <see cref="Order"/> — including its back-reference to the
|
||||||
/// <summary>DTO carrying an <see cref="Order"/> — including its client back-reference — to the WebApp; record: value equality.</summary>
|
/// owning <see cref="ClientDto"/> — to the WebApp. A record: value equality
|
||||||
|
/// keeps round-trip comparisons trivial.
|
||||||
|
/// </summary>
|
||||||
public sealed record OrderDto
|
public sealed record OrderDto
|
||||||
{
|
{
|
||||||
/// <summary>Primary key of the underlying order.</summary>
|
/// <summary>Primary key of the underlying order.</summary>
|
||||||
@@ -14,6 +16,9 @@ public sealed record OrderDto
|
|||||||
/// <summary>Order total in cents of the underlying order.</summary>
|
/// <summary>Order total in cents of the underlying order.</summary>
|
||||||
public int TotalInCents { get; init; }
|
public int TotalInCents { get; init; }
|
||||||
|
|
||||||
/// <summary>Back-reference to the owning client, or null for a bare order.</summary>
|
/// <summary>
|
||||||
|
/// Back-reference to the owning client, or <c>null</c> for a bare order
|
||||||
|
/// that has not been added to any client.
|
||||||
|
/// </summary>
|
||||||
public ClientDto? ClientDto { get; init; }
|
public ClientDto? ClientDto { get; init; }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace After;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The consuming application, after situation: it consumes only DTOs
|
||||||
|
/// (<see cref="ClientDto"/>/<see cref="OrderDto"/>). Its public API references
|
||||||
|
/// no domain type (Client/Order) and no persistence type
|
||||||
|
/// (DbContext/DbBase), so the WebApp can never reach the persistence layer
|
||||||
|
/// through them — the leak the before situation exposed is gone.
|
||||||
|
/// </summary>
|
||||||
|
public class WebApp
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Render a client together with all of its orders, consuming the
|
||||||
|
/// <see cref="ClientDto"/> (and its nested <see cref="OrderDto"/>)s only.
|
||||||
|
/// </summary>
|
||||||
|
public string ShowClient(ClientDto client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
|
||||||
|
var lines = new List<string> { $"{client.Name} [{client.Id}]" };
|
||||||
|
foreach (var order in client.Orders)
|
||||||
|
lines.Add($" - order [{order.Id}]: {order.Description}");
|
||||||
|
return string.Join(Environment.NewLine, lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Domain\Domain.csproj" />
|
<ProjectReference Include="..\Before\Before.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using Before;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Before.Console — a straight-line demo of the ActiveRecord-style pattern.
|
||||||
|
//
|
||||||
|
// The console app sets DbBase.Context (the shared singleton) once at startup,
|
||||||
|
// then creates client/order entities, saves them through the context, and
|
||||||
|
// queries back using the ActiveRecord entry points:
|
||||||
|
// • Client.FindByName(name) — no context argument: rides the
|
||||||
|
// DbBase.Context singleton
|
||||||
|
// • listing via DbBase.Context!.Tracked
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var ctx = new DbContext();
|
||||||
|
DbBase.Context = ctx;
|
||||||
|
|
||||||
|
// ----- seed data ----------------------------------------------------------
|
||||||
|
var jane = new Client { Name = "Jane Doe" };
|
||||||
|
var bob = new Client { Name = "Bob Smith" };
|
||||||
|
|
||||||
|
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", TotalInCents = 85_00 });
|
||||||
|
|
||||||
|
// Orders need to be attached too so they get IDs:
|
||||||
|
ctx.Attach(jane.Orders[0]);
|
||||||
|
ctx.Attach(jane.Orders[1]);
|
||||||
|
ctx.Attach(bob.Orders[0]);
|
||||||
|
|
||||||
|
ctx.Attach(jane);
|
||||||
|
ctx.Attach(bob);
|
||||||
|
ctx.Save();
|
||||||
|
|
||||||
|
// ----- list all clients ---------------------------------------------------
|
||||||
|
Console.WriteLine("=== All Clients ===");
|
||||||
|
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} ({order.TotalInCents} cents)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- find by name -------------------------------------------------------
|
||||||
|
Console.WriteLine("\n=== Find by Name (null-returning) ===");
|
||||||
|
// No context argument: FindByName falls back to the DbBase.Context singleton.
|
||||||
|
var found = Client.FindByName("Jane Doe");
|
||||||
|
if (found is not null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Found: {found.Name} [{found.Id}]");
|
||||||
|
Console.WriteLine($" Orders: {found.Orders.Count}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("Not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- find by name (throwing variant) ------------------------------------
|
||||||
|
Console.WriteLine("\n=== Find by Name Required (throws instead of returning null) ===");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var required = Client.FindByNameRequired("Jane Doe");
|
||||||
|
Console.WriteLine($"Found: {required.Name} [{required.Id}]");
|
||||||
|
Console.WriteLine($" Orders: {required.Orders.Count}");
|
||||||
|
}
|
||||||
|
catch (ObjectNotFoundException ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Not found: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the contrast with a non-existent name:
|
||||||
|
Console.WriteLine("\n=== Miss: null-returning vs throwing ===");
|
||||||
|
var missNull = Client.FindByName("Nobody Here");
|
||||||
|
Console.WriteLine($"FindByName(\"Nobody Here\"): {(missNull == null ? "null" : missNull.Name)}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Client.FindByNameRequired("Nobody Here");
|
||||||
|
}
|
||||||
|
catch (ObjectNotFoundException ex)
|
||||||
|
{
|
||||||
|
var msg = ex.Message.ReplaceLineEndings(" ").Trim();
|
||||||
|
Console.WriteLine($"FindByNameRequired(\"Nobody Here\"): throws - {msg}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace Before;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A client, backed by the database: it derives from <see cref="DbBase"/> and
|
||||||
|
/// holds its orders as a navigation collection. In the "after" situation this
|
||||||
|
/// becomes a plain POCO (no <see cref="DbBase"/>) with a <c>ClientDto</c>
|
||||||
|
/// carrying its data to the WebApp.
|
||||||
|
/// </summary>
|
||||||
|
public class Client : DbBase
|
||||||
|
{
|
||||||
|
/// <summary>Display name of the client.</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Navigation collection of this client's orders. Adding to it performs
|
||||||
|
/// EF-style navigation fix-up (see <see cref="ClientOrders"/>).
|
||||||
|
/// </summary>
|
||||||
|
public ClientOrders Orders { get; }
|
||||||
|
|
||||||
|
public Client()
|
||||||
|
{
|
||||||
|
Orders = new ClientOrders(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience lookup: finds the first <see cref="Client"/> whose
|
||||||
|
/// <see cref="Name"/> equals <paramref name="name"/>. Returns
|
||||||
|
/// <c>null</c> when no match.
|
||||||
|
///
|
||||||
|
/// This mirrors how an ActiveRecord-style ORM might surface a static
|
||||||
|
/// finder on the domain class itself — it rides the <c>DbContext</c>
|
||||||
|
/// leak from the entity's back-reference. Delegates to
|
||||||
|
/// <see cref="DbBase.Find{T}"/> for the common traversal logic.
|
||||||
|
///
|
||||||
|
/// The context is optional: when omitted, the <see cref="DbBase.Context"/>
|
||||||
|
/// singleton is used, so call sites read like
|
||||||
|
/// <c>Client.FindByName("Jane Doe")</c> — no context threading required.
|
||||||
|
/// </summary>
|
||||||
|
public static Client? FindByName(string name, DbContext? db = null)
|
||||||
|
=> DbBase.Find<Client>(c => c.Name == name, db);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Throwing variant of <see cref="FindByName(string,DbContext?)"/>. Returns
|
||||||
|
/// the matching <see cref="Client"/> or throws <see cref="ObjectNotFoundException"/>
|
||||||
|
/// with a message that includes the searched <paramref name="name"/>.
|
||||||
|
/// Throws <see cref="InvalidOperationException"/> instead when no context at all
|
||||||
|
/// is configured (a configuration error, not a lookup miss).
|
||||||
|
///
|
||||||
|
/// Delegates to <see cref="DbBase.FindRequired{T}"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The client name to search for.</param>
|
||||||
|
/// <param name="db">The context whose tracked entities to search. When
|
||||||
|
/// <c>null</c>, falls back to the <see cref="DbBase.Context"/> singleton.</param>
|
||||||
|
/// <returns>The first client whose <see cref="Client.Name"/> equals <paramref name="name"/>.</returns>
|
||||||
|
/// <exception cref="ObjectNotFoundException">
|
||||||
|
/// Thrown when no matching client is found.
|
||||||
|
/// </exception>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when no context is configured (neither passed explicitly nor set as the singleton).
|
||||||
|
/// </exception>
|
||||||
|
public static Client FindByNameRequired(string name, DbContext? db = null)
|
||||||
|
=> DbBase.FindRequired<Client>(c => c.Name == name, db, $"name == \"{name}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An <see cref="Order"/> collection that fakes EF's navigation fix-up: when an
|
||||||
|
/// order is added, its <see cref="Order.Client"/> back-reference is set to the
|
||||||
|
/// owning client, exactly as EF would wire up the two ends of the relation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClientOrders : Collection<Order>
|
||||||
|
{
|
||||||
|
private readonly Client _owner;
|
||||||
|
|
||||||
|
public ClientOrders(Client owner)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both Add(...) and Insert(...) funnel through InsertItem, so overriding
|
||||||
|
// it covers every way an order can be added to the collection.
|
||||||
|
protected override void InsertItem(int index, Order item)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
item.Client = _owner; // navigation fix-up: order now knows its client
|
||||||
|
base.InsertItem(index, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
namespace Before;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for every DB-backed domain object. Fakes the active-record part
|
||||||
|
/// of EF: each entity carries its own <see cref="Id"/> and, once it has been
|
||||||
|
/// created/saved through a context, a back-reference to that context.
|
||||||
|
///
|
||||||
|
/// <see cref="DbContext"/> is the dependency this base class leaks. The
|
||||||
|
/// "after" situation (Yak 02) removes this inheritance entirely — domain
|
||||||
|
/// objects become plain POCOs that know nothing about a DbContext.
|
||||||
|
/// </summary>
|
||||||
|
public class DbBase
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Static singleton: an ActiveRecord-style shared context that any
|
||||||
|
// entity can reach without passing it through method parameters.
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
/// <summary>
|
||||||
|
/// Shared (singleton) <see cref="DbContext" /> accessible from every
|
||||||
|
/// entity via its base type. Set once at application startup so that
|
||||||
|
/// entity methods can call <c>DbBase.Context!</c> instead of carrying
|
||||||
|
/// a context reference.
|
||||||
|
///
|
||||||
|
/// This is yet another leak: domain objects depend on the persistence
|
||||||
|
/// layer at the *type* level, not just the instance level.
|
||||||
|
/// </summary>
|
||||||
|
public static DbContext? Context { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key. Fresh (unsaved) entities have <see cref="Guid.Empty"/>;
|
||||||
|
/// <see cref="DbContext.Save"/> assigns a real id, faking EF's identity
|
||||||
|
/// generation.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Active-record back-reference: "I know which context created me". This
|
||||||
|
/// is the leak the exercise exposes — from a plain domain object you can
|
||||||
|
/// reach straight into the persistence layer. <c>internal set</c> because
|
||||||
|
/// only the owning <see cref="DbContext"/> may (re)assign it; consumers
|
||||||
|
/// (including the WebApp, and the tests) can only read it.
|
||||||
|
/// </summary>
|
||||||
|
public DbContext? DbContext { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generic ActiveRecord-style finder: walks the <paramref name="db"/
|
||||||
|
/// />'s tracked entities, looks for the first whose runtime type matches
|
||||||
|
/// <typeparamref name="T"/> and satisfies <paramref name="predicate"/>
|
||||||
|
///.
|
||||||
|
///
|
||||||
|
/// This static generic rides the <c>DbContext</c> leak just like
|
||||||
|
/// <see cref="Client.FindByName"/>, but works for any <see cref="DbBase"
|
||||||
|
/// /> subtype without each entity needing its own hand-written finder.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Entity type to find (must derive from <see cref="DbBase"/>).
|
||||||
|
/// </typeparam>
|
||||||
|
/// <param name="predicate">Filter applied to candidates of type <typeparamref name="T"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="db">The context whose tracked entities to search.
|
||||||
|
/// When <c>null</c>, falls back to the <see cref="Context"/> singleton —
|
||||||
|
/// so a caller only passes a context explicitly when it must differ from
|
||||||
|
/// the ambient one.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The first matching entity, or <c>null</c> when no match.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Note the (deliberate, ActiveRecord-style) ambiguity this still leaves:
|
||||||
|
/// a <c>null</c> result means "no match — or no context configured at
|
||||||
|
/// all". The caller cannot distinguish the two; that is part of the cost
|
||||||
|
/// of the static-singleton leak this exercise illustrates.
|
||||||
|
/// </remarks>
|
||||||
|
public static T? Find<T>(Predicate<T> predicate, DbContext? db) where T : DbBase
|
||||||
|
{
|
||||||
|
db ??= Context;
|
||||||
|
|
||||||
|
if (db is null)
|
||||||
|
return default;
|
||||||
|
|
||||||
|
foreach (var e in db.Tracked)
|
||||||
|
if (e is T candidate && predicate(candidate))
|
||||||
|
return candidate;
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Throwing variant of <see cref="Find{T}(System.Predicate{T},DbContext?)"/>. Finds the first entity
|
||||||
|
/// whose runtime type matches <typeparamref name="T"/> and satisfies
|
||||||
|
/// <paramref name="predicate"/>. Throws <see cref="ObjectNotFoundException"/>
|
||||||
|
/// when no match is found, and <see cref="InvalidOperationException"/> when no
|
||||||
|
/// <see cref="Context"/> singleton is configured (a configuration error, not a lookup miss).
|
||||||
|
///
|
||||||
|
/// The exception message includes <paramref name="predicateToString"/> so
|
||||||
|
/// callers can debug which lookup failed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="predicate">Filter applied to candidates of type <typeparamref name="T"/>.</param>
|
||||||
|
/// <param name="db">The context whose tracked entities to search. When <c>null</c>, falls back
|
||||||
|
/// to the <see cref="Context"/> singleton.</param>
|
||||||
|
/// <param name="predicateToString">A human-readable description of the predicate, used in the
|
||||||
|
/// exception message when the search fails.</param>
|
||||||
|
/// <returns>The first matching entity.</returns>
|
||||||
|
/// <exception cref="ObjectNotFoundException">
|
||||||
|
/// Thrown when no entity matches <paramref name="predicate"/>.
|
||||||
|
/// </exception>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when no context is available (neither passed explicitly nor set as the singleton).
|
||||||
|
/// </exception>
|
||||||
|
public static T FindRequired<T>(Predicate<T> predicate, DbContext? db, string predicateToString)
|
||||||
|
where T : DbBase
|
||||||
|
{
|
||||||
|
db ??= Context;
|
||||||
|
|
||||||
|
if (db is null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Cannot perform FindRequired<{typeof(T).Name}>: " +
|
||||||
|
$"no DbContext configured (neither passed explicitly nor set as " +
|
||||||
|
$"DbBase.Context singleton). Set DbBase.Context before calling " +
|
||||||
|
$"FindRequired<{typeof(T).Name}>.");
|
||||||
|
|
||||||
|
foreach (var e in db.Tracked)
|
||||||
|
if (e is T candidate && predicate(candidate))
|
||||||
|
return candidate;
|
||||||
|
|
||||||
|
throw new ObjectNotFoundException(
|
||||||
|
$"FindRequired<{typeof(T).Name}>({predicateToString}) — " +
|
||||||
|
$"no matching {typeof(T).Name} found in context.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace Before;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A tiny hand-rolled fake of EF's <c>DbContext</c>: it keeps per-type
|
||||||
|
/// change-tracker tables (mirroring EF's <see cref="DbSet{T}"/> pattern) and
|
||||||
|
/// a <see cref="Save"/> that fakes <c>SaveChangesAsync</c>.
|
||||||
|
///
|
||||||
|
/// This is deliberately NOT real EF Core — no providers, no SQLite, no NuGet
|
||||||
|
/// beyond xUnit. The point of the exercise is the *shape of the dependencies*
|
||||||
|
/// (domain object -> DbContext), not EF's behaviour.
|
||||||
|
/// </summary>
|
||||||
|
public class DbContext
|
||||||
|
{
|
||||||
|
// Per-type tables: each entity type has its own typed collection,
|
||||||
|
// mimicking EF Core's <see cref="DbSet{T}"/> model where the DbContext
|
||||||
|
// maintains a separate set per entity type.
|
||||||
|
private readonly Dictionary<Type, Collection<DbBase>> _tables = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get or create the per-type table for <typeparamref name="T"/>.
|
||||||
|
/// This mirrors EF's <see cref="DbSet{T}"/> / <c>Set<T></c> accessor.
|
||||||
|
/// </summary>
|
||||||
|
private Collection<DbBase> Table<T>() where T : DbBase
|
||||||
|
{
|
||||||
|
var type = typeof(T);
|
||||||
|
if (!_tables.TryGetValue(type, out var table))
|
||||||
|
{
|
||||||
|
table = new Collection<DbBase>();
|
||||||
|
_tables[type] = table;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get or create the per-type table for the given runtime <paramref name="type"/>.
|
||||||
|
/// Called from <see cref="Attach"/> where we only know the type at runtime.
|
||||||
|
/// </summary>
|
||||||
|
private Collection<DbBase> TableFor(Type type)
|
||||||
|
{
|
||||||
|
if (!_tables.TryGetValue(type, out var table))
|
||||||
|
{
|
||||||
|
table = new Collection<DbBase>();
|
||||||
|
_tables[type] = table;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attach an entity to this context (EF's <c>Add</c>). Sets the
|
||||||
|
/// active-record back-reference so the entity knows its owner.
|
||||||
|
/// The entity is placed into the per-type table that matches its
|
||||||
|
/// runtime type — just as EF writes rows to the correct table.
|
||||||
|
/// </summary>
|
||||||
|
public void Attach(DbBase entity)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entity);
|
||||||
|
entity.DbContext = this;
|
||||||
|
var table = TableFor(entity.GetType());
|
||||||
|
if (!table.Contains(entity))
|
||||||
|
table.Add(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fake <c>SaveChangesAsync</c>: walk all per-type tables and assign a
|
||||||
|
/// fresh <see cref="Guid"/> to any whose id is still <see cref="Guid.Empty"/>.
|
||||||
|
/// Returns the number of entities that were (re)saved — i.e. newly
|
||||||
|
/// identified — mirroring EF's "rows written" return value.
|
||||||
|
/// </summary>
|
||||||
|
public int Save()
|
||||||
|
{
|
||||||
|
var saved = 0;
|
||||||
|
foreach (var table in _tables.Values)
|
||||||
|
foreach (var entity in table)
|
||||||
|
if (entity.Id == Guid.Empty)
|
||||||
|
{
|
||||||
|
entity.Id = Guid.NewGuid();
|
||||||
|
saved++;
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Find the first entity of type <typeparamref name="T"/> in this
|
||||||
|
/// context's per-type table that satisfies <paramref name="predicate"/>.
|
||||||
|
/// Only checks entities whose runtime type exactly matches <typeparamref name="T"/>
|
||||||
|
/// — just as EF's <c>DbSet{T}.Find</c> operates on a single table.
|
||||||
|
///
|
||||||
|
/// This instance-level finder complements the static
|
||||||
|
/// <see cref="DbBase.Find{T}(Predicate{T},DbContext?)"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Entity type to find (must derive from <see cref="DbBase"/>).</typeparam>
|
||||||
|
/// <param name="predicate">Filter applied to candidates of type <typeparamref name="T"/>.</param>
|
||||||
|
/// <returns>The first matching entity, or <c>null</c> when no match.</returns>
|
||||||
|
public T? Find<T>(Predicate<T> predicate) where T : DbBase
|
||||||
|
{
|
||||||
|
var table = Table<T>();
|
||||||
|
foreach (var e in table)
|
||||||
|
if (e is T candidate && predicate(candidate))
|
||||||
|
return candidate;
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if <paramref name="entity"/> is in this context's tracker
|
||||||
|
/// (walks all per-type tables).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsTracked(DbBase entity)
|
||||||
|
{
|
||||||
|
foreach (var table in _tables.Values)
|
||||||
|
if (table.Contains(entity))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only view of all entities this context currently tracks
|
||||||
|
/// (flattened across all per-type tables).
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<DbBase> Tracked
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var all = new List<DbBase>();
|
||||||
|
foreach (var table in _tables.Values)
|
||||||
|
all.AddRange(table);
|
||||||
|
return all.AsReadOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Before;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a "required" ActiveRecord-style lookup —
|
||||||
|
/// <see cref="DbBase.FindRequired{T}"/> / <see cref="Client.FindByNameRequired"/> —
|
||||||
|
/// finds no matching entity in the context.
|
||||||
|
///
|
||||||
|
/// Named after NHibernate's <c>ObjectNotFoundException</c> (Rails' ActiveRecord
|
||||||
|
/// raises <c>ActiveRecord::RecordNotFound</c> for the same situation), so a
|
||||||
|
/// lookup miss is distinguishable from unrelated
|
||||||
|
/// <see cref="InvalidOperationException"/>s such as EF's <c>Single()</c>
|
||||||
|
/// "Sequence contains no elements".
|
||||||
|
/// </summary>
|
||||||
|
public class ObjectNotFoundException : Exception
|
||||||
|
{
|
||||||
|
public ObjectNotFoundException(string message) : base(message) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Before;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An order, backed by the database: it derives from <see cref="DbBase"/> and
|
||||||
|
/// holds a back-reference to its <see cref="Client"/> (the other end of the
|
||||||
|
/// relation). In the "after" situation this becomes a plain POCO with an
|
||||||
|
/// <c>OrderDto</c> carrying its data to the WebApp.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
/// <see cref="Client.Orders"/> (navigation fix-up).
|
||||||
|
/// </summary>
|
||||||
|
public Client? Client { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace Before;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The consuming application. In the "before" situation it talks to the
|
||||||
|
/// DB-backed domain model directly: its methods take <see cref="Client"/> and
|
||||||
|
/// <see cref="Order"/> (which inherit <see cref="DbBase"/>) as parameters.
|
||||||
|
///
|
||||||
|
/// One of its methods reaches the <see cref="DbContext"/> *through* a domain
|
||||||
|
/// object — the leak. In the "after" situation (Yak 02) the WebApp consumes
|
||||||
|
/// DTOs instead, and the domain objects no longer expose a DbContext.
|
||||||
|
/// </summary>
|
||||||
|
public class WebApp
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Render a client together with all of its orders, consuming the
|
||||||
|
/// <see cref="Client"/>/ <see cref="Order"/> domain objects directly.
|
||||||
|
/// </summary>
|
||||||
|
public string ShowClient(Client client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
|
||||||
|
var lines = new List<string> { $"{client.Name} [{client.Id}]" };
|
||||||
|
foreach (var order in client.Orders)
|
||||||
|
lines.Add($" - order [{order.Id}]: {order.Description}");
|
||||||
|
return string.Join(Environment.NewLine, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The leak, made concrete: from a plain domain object the WebApp can reach
|
||||||
|
/// the <see cref="DbContext"/> (<see cref="DbBase.DbContext"/>) and thus
|
||||||
|
/// touch the persistence layer — here just to ask whether the client has
|
||||||
|
/// been saved. The "after" situation removes <c>client.DbContext</c>
|
||||||
|
/// entirely, so no DTO-consumer can do this.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPersisted(Client client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
return client.DbContext is not null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace Db;
|
|
||||||
|
|
||||||
/// <summary>Persisted shape of a <see cref="Client"/>: data only.</summary>
|
|
||||||
public sealed class ClientData : DbBase
|
|
||||||
{
|
|
||||||
/// <summary>Display name of the client.</summary>
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace Db;
|
|
||||||
|
|
||||||
/// <summary>Hand-written mappers between domain objects and persistence records; each method maps one direction and never mutates its input.</summary>
|
|
||||||
public static class DataMappers
|
|
||||||
{
|
|
||||||
/// <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 client back-reference becomes a foreign key.</summary>
|
|
||||||
public static OrderData ToOrderData(Order order)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(order);
|
|
||||||
return new OrderData
|
|
||||||
{
|
|
||||||
Id = order.Id,
|
|
||||||
Description = order.Description,
|
|
||||||
TotalInCents = order.TotalInCents.Cents, // value object → plain int for the row
|
|
||||||
ClientId = order.Client?.Id ?? Guid.Empty,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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"/>; the context reassembles the graph.</summary>
|
|
||||||
public static Order FromOrderData(OrderData row)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(row);
|
|
||||||
return new Order
|
|
||||||
{
|
|
||||||
Id = row.Id,
|
|
||||||
Description = row.Description,
|
|
||||||
TotalInCents = OrderMoney.From(row.TotalInCents), // plain int from the row → value object
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace Db;
|
|
||||||
|
|
||||||
/// <summary>Persistence framework base class: only the data records derive from it, never the domain objects.</summary>
|
|
||||||
public class DbBase
|
|
||||||
{
|
|
||||||
/// <summary>Primary key; <see cref="Guid.Empty"/> until the context assigns one on save.</summary>
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Back-reference to the owning <see cref="DbContext"/>; lives on persistence records only.</summary>
|
|
||||||
public DbContext? DbContext { get; internal set; }
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace Db;
|
|
||||||
|
|
||||||
/// <summary>The fake DbContext: per-type tables of data-only records (never domain objects); Save inserts-or-refreshes rows, Load reassembles domain objects.</summary>
|
|
||||||
public class DbContext
|
|
||||||
{
|
|
||||||
private readonly List<ClientData> _clients = new();
|
|
||||||
private readonly List<OrderData> _orders = new();
|
|
||||||
|
|
||||||
// domain object reference → its persisted record
|
|
||||||
private readonly Dictionary<object, DbBase> _byRef = new();
|
|
||||||
|
|
||||||
public IReadOnlyList<ClientData> Clients => _clients.AsReadOnly();
|
|
||||||
public IReadOnlyList<OrderData> Orders => _orders.AsReadOnly();
|
|
||||||
|
|
||||||
/// <summary>The strongly-typed "table" for <typeparamref name="T"/>.</summary>
|
|
||||||
public IReadOnlyList<T> Set<T>() where T : DbBase
|
|
||||||
=> typeof(T) switch
|
|
||||||
{
|
|
||||||
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)}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>Persist a <see cref="Client"/>: insert on first save (assigning its id), refresh in place on later ones.</summary>
|
|
||||||
public void Save(Client client)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(client);
|
|
||||||
var fresh = DataMappers.ToClientData(client);
|
|
||||||
if (_byRef.TryGetValue(client, out var existing))
|
|
||||||
{
|
|
||||||
var row = (ClientData)existing; // refresh, keep row identity
|
|
||||||
row.Name = fresh.Name;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var id = Guid.NewGuid();
|
|
||||||
fresh.Id = client.Id = id; // write-back identity
|
|
||||||
fresh.DbContext = this;
|
|
||||||
_clients.Add(fresh);
|
|
||||||
_byRef[client] = fresh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Persist an <see cref="Order"/> (same insert-or-refresh rule; save its client first so the foreign key can be filled).</summary>
|
|
||||||
public void Save(Order order)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(order);
|
|
||||||
var fresh = DataMappers.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();
|
|
||||||
fresh.Id = order.Id = id; // write-back identity
|
|
||||||
fresh.DbContext = this;
|
|
||||||
_orders.Add(fresh);
|
|
||||||
_byRef[order] = fresh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Load the <see cref="Client"/> stored under <paramref name="id"/> with all of its orders, or null.</summary>
|
|
||||||
public Client? LoadClient(Guid id)
|
|
||||||
{
|
|
||||||
var row = Find<ClientData>(r => r.Id == id);
|
|
||||||
if (row is null) return null;
|
|
||||||
var client = DataMappers.FromClientData(row);
|
|
||||||
foreach (var orderRow in _orders.Where(o => o.ClientId == id))
|
|
||||||
client.Orders.Add(DataMappers.FromOrderData(orderRow)); // fix-up wires Order.Client
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Load the <see cref="Order"/> stored under <paramref name="id"/> with its client, or null; use <see cref="LoadClient"/> for a client with all its orders.</summary>
|
|
||||||
public Order? LoadOrder(Guid id)
|
|
||||||
{
|
|
||||||
var row = Find<OrderData>(r => r.Id == id);
|
|
||||||
if (row is null) return null;
|
|
||||||
var order = DataMappers.FromOrderData(row);
|
|
||||||
if (row.ClientId != Guid.Empty)
|
|
||||||
{
|
|
||||||
var clientRow = Find<ClientData>(c => c.Id == row.ClientId);
|
|
||||||
if (clientRow is not null)
|
|
||||||
{
|
|
||||||
var client = DataMappers.FromClientData(clientRow);
|
|
||||||
client.Orders.Add(order); // fix-up wires Order.Client
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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);
|
|
||||||
|
|
||||||
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary>
|
|
||||||
public bool IsTracked(object entity) => _byRef.ContainsKey(entity);
|
|
||||||
|
|
||||||
/// <summary>All data records this context currently manages.</summary>
|
|
||||||
public IReadOnlyList<DbBase> Tracked => _byRef.Values.ToList();
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace Db;
|
|
||||||
|
|
||||||
/// <summary>Persisted shape of an <see cref="Order"/>: data only, object graph flattened to ids.</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>Order total in cents of the stored order.</summary>
|
|
||||||
public int TotalInCents { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Foreign key to the owning client; empty when the order has no client.</summary>
|
|
||||||
public Guid ClientId { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
|
|
||||||
namespace Domain;
|
|
||||||
|
|
||||||
/// <summary>A plain domain object: no persistence base class, no persistence context knowledge.</summary>
|
|
||||||
public class Client
|
|
||||||
{
|
|
||||||
/// <summary>Identity: <see cref="Guid.Empty"/> until the persistence layer assigns one on save.</summary>
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Display name of the client.</summary>
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>The client's orders; adding one wires its <see cref="Order.Client"/> back-reference.</summary>
|
|
||||||
public ClientOrders Orders { get; }
|
|
||||||
|
|
||||||
public Client()
|
|
||||||
{
|
|
||||||
Orders = new ClientOrders(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>An <see cref="Order"/> collection that fakes EF's navigation fix-up.</summary>
|
|
||||||
public sealed class ClientOrders : Collection<Order>
|
|
||||||
{
|
|
||||||
private readonly Client _owner;
|
|
||||||
|
|
||||||
public ClientOrders(Client owner)
|
|
||||||
{
|
|
||||||
_owner = owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add(...) and Insert(...) both funnel through InsertItem.
|
|
||||||
protected override void InsertItem(int index, Order item)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(item);
|
|
||||||
item.Client = _owner; // navigation fix-up
|
|
||||||
base.InsertItem(index, item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace Domain;
|
|
||||||
|
|
||||||
/// <summary>A plain domain order: no persistence base class, no persistence context knowledge.</summary>
|
|
||||||
public class Order
|
|
||||||
{
|
|
||||||
/// <summary>Identity: <see cref="Guid.Empty"/> until the persistence layer assigns one on save.</summary>
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Free-text description of what this order is for.</summary>
|
|
||||||
public string Description { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Order total as a value object; the DTO and storage row stay plain ints — the mappers convert.</summary>
|
|
||||||
public OrderMoney TotalInCents { get; set; } = OrderMoney.Default;
|
|
||||||
|
|
||||||
/// <summary>The owning client; wired by navigation fix-up when the order is added to <see cref="Client.Orders"/>.</summary>
|
|
||||||
public Client? Client { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
namespace Domain;
|
|
||||||
|
|
||||||
/// <summary>Money value object: non-negative whole cents; invalid amounts are unrepresentable. Record: value equality.</summary>
|
|
||||||
public sealed record OrderMoney
|
|
||||||
{
|
|
||||||
private OrderMoney(int cents) => Cents = cents;
|
|
||||||
|
|
||||||
/// <summary>The only validating construction path.</summary>
|
|
||||||
public static OrderMoney From(int cents)
|
|
||||||
{
|
|
||||||
ArgumentOutOfRangeException.ThrowIfNegative(cents);
|
|
||||||
return new OrderMoney(cents);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Zero sentinel for member initializers (like <see cref="Guid.Empty"/>); internal to this assembly.</summary>
|
|
||||||
internal static OrderMoney Default { get; } = new(0);
|
|
||||||
|
|
||||||
/// <summary>The raw amount in cents.</summary>
|
|
||||||
public int Cents { get; }
|
|
||||||
|
|
||||||
/// <summary>Explicit conversions only; int → OrderMoney validates exactly like <see cref="From"/>.</summary>
|
|
||||||
public static explicit operator OrderMoney(int cents) => From(cents);
|
|
||||||
|
|
||||||
public static explicit operator int(OrderMoney money)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(money);
|
|
||||||
return money.Cents;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() => Cents.ToString();
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace Web;
|
|
||||||
|
|
||||||
/// <summary>DTO carrying a <see cref="Client"/> — with its orders — to the WebApp; record: value equality keeps round-trip comparisons trivial.</summary>
|
|
||||||
public sealed record ClientDto
|
|
||||||
{
|
|
||||||
/// <summary>Primary key of the underlying client.</summary>
|
|
||||||
public Guid Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Display name of the client.</summary>
|
|
||||||
public string Name { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>The client's orders, each carrying a back-reference to this DTO; settable so the mappers can fill it in a second pass.</summary>
|
|
||||||
public IReadOnlyList<OrderDto> Orders { get; set; } = Array.Empty<OrderDto>();
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace Web;
|
|
||||||
|
|
||||||
/// <summary>Hand-written mappers between domain objects and DTOs; each method maps one direction and never mutates its input. The client/order cycle is resolved with a reference-identity cache.</summary>
|
|
||||||
public static class DtoMappers
|
|
||||||
{
|
|
||||||
/// <summary>Map a <see cref="Client"/> (with its orders) to a <see cref="ClientDto"/>.</summary>
|
|
||||||
public static ClientDto ToClientDto(Client client)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(client);
|
|
||||||
return MapClient(client, new Dictionary<object, object>());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Map an <see cref="Order"/> (with its client back-reference) to an <see cref="OrderDto"/>.</summary>
|
|
||||||
public static OrderDto ToOrderDto(Order order)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(order);
|
|
||||||
return MapOrder(order, new Dictionary<object, object>());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Map a <see cref="ClientDto"/> (with its orders) back to a <see cref="Client"/>.</summary>
|
|
||||||
public static Client FromClientDto(ClientDto dto)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(dto);
|
|
||||||
return UnmapClient(dto, new Dictionary<object, object>());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Map an <see cref="OrderDto"/> (with its client back-reference) back to an <see cref="Order"/>.</summary>
|
|
||||||
public static Order FromOrderDto(OrderDto dto)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(dto);
|
|
||||||
return UnmapOrder(dto, new Dictionary<object, object>());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ClientDto MapClient(Client client, Dictionary<object, object> seen)
|
|
||||||
{
|
|
||||||
if (seen.TryGetValue(client, out var cached))
|
|
||||||
return (ClientDto)cached;
|
|
||||||
|
|
||||||
var dto = new ClientDto { Id = client.Id, Name = client.Name };
|
|
||||||
seen[client] = dto; // cache first: breaks the cycle
|
|
||||||
dto.Orders = client.Orders
|
|
||||||
.Select(o => MapOrder(o, seen))
|
|
||||||
.ToList(); // fill second, through the cache
|
|
||||||
return dto;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static OrderDto MapOrder(Order order, Dictionary<object, object> seen)
|
|
||||||
{
|
|
||||||
if (seen.TryGetValue(order, out var cached))
|
|
||||||
return (OrderDto)cached;
|
|
||||||
|
|
||||||
var dto = new OrderDto
|
|
||||||
{
|
|
||||||
Id = order.Id,
|
|
||||||
Description = order.Description,
|
|
||||||
TotalInCents = order.TotalInCents.Cents, // value object → plain int for the DTO
|
|
||||||
ClientDto = order.Client is null ? null : MapClient(order.Client, seen),
|
|
||||||
};
|
|
||||||
seen[order] = dto;
|
|
||||||
return dto;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Client UnmapClient(ClientDto dto, Dictionary<object, object> seen)
|
|
||||||
{
|
|
||||||
if (seen.TryGetValue(dto, out var cached))
|
|
||||||
return (Client)cached;
|
|
||||||
|
|
||||||
var client = new Client { Id = dto.Id, Name = dto.Name };
|
|
||||||
seen[dto] = client; // cache first: breaks the cycle
|
|
||||||
foreach (var orderDto in dto.Orders)
|
|
||||||
client.Orders.Add(UnmapOrder(orderDto, seen)); // fix-up wires order.Client
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Order UnmapOrder(OrderDto dto, Dictionary<object, object> seen)
|
|
||||||
{
|
|
||||||
if (seen.TryGetValue(dto, out var cached))
|
|
||||||
return (Order)cached;
|
|
||||||
|
|
||||||
var order = new Order
|
|
||||||
{
|
|
||||||
Id = dto.Id,
|
|
||||||
Description = dto.Description,
|
|
||||||
TotalInCents = OrderMoney.From(dto.TotalInCents), // plain int from the DTO → value object
|
|
||||||
Client = dto.ClientDto is null ? null : UnmapClient(dto.ClientDto, seen),
|
|
||||||
};
|
|
||||||
seen[dto] = order;
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Deliberately NO reference to Db: the Web layer consumes DTOs
|
|
||||||
only, so it cannot reach the persistence layer — the leak the
|
|
||||||
exercise illustrates is now enforced by the compiler. -->
|
|
||||||
<ProjectReference Include="..\Domain\Domain.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
namespace Web;
|
|
||||||
|
|
||||||
/// <summary>The consuming application: it sees only DTOs, never domain or persistence types.</summary>
|
|
||||||
public class WebApp
|
|
||||||
{
|
|
||||||
/// <summary>Render a client together with all of its orders, consuming DTOs only.</summary>
|
|
||||||
public string ShowClient(ClientDto client)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(client);
|
|
||||||
|
|
||||||
var lines = new List<string> { $"{client.Name} [{client.Id}]" };
|
|
||||||
foreach (var order in client.Orders)
|
|
||||||
lines.Add($" - order [{order.Id}]: {order.Description}");
|
|
||||||
return string.Join(Environment.NewLine, lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+17
-57
@@ -1,10 +1,16 @@
|
|||||||
using Domain;
|
using After;
|
||||||
using static TestData.ClientBuilder;
|
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||||
using static TestData.OrderBuilder;
|
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||||
|
|
||||||
namespace Db.Tests;
|
namespace BeforeAfter.Tests;
|
||||||
|
|
||||||
/// <summary>The fake <see cref="DbContext"/> stores data-only records, never domain objects; Save maps state into rows (insert or refresh), the graph flattened to ids.</summary>
|
/// <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
|
public class AfterPersistenceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -49,7 +55,7 @@ public class AfterPersistenceTests
|
|||||||
db.Save(order);
|
db.Save(order);
|
||||||
|
|
||||||
order.Description = "one (edited)";
|
order.Description = "one (edited)";
|
||||||
order.TotalInCents = OrderMoney.From(99_00);
|
order.TotalInCents = 99_00;
|
||||||
client.Name = "Acme GmbH";
|
client.Name = "Acme GmbH";
|
||||||
db.Save(order);
|
db.Save(order);
|
||||||
db.Save(client);
|
db.Save(client);
|
||||||
@@ -82,7 +88,10 @@ public class AfterPersistenceTests
|
|||||||
Assert.Null(db.Find<OrderData>(row => row.Description == "no such order"));
|
Assert.Null(db.Find<OrderData>(row => row.Description == "no such order"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read-back: LoadOrder / LoadClient reassemble domain objects from the stored rows.
|
// ---------------------------------------------------------------------
|
||||||
|
// Read-back: LoadOrder / LoadClient reassemble domain objects from the
|
||||||
|
// stored rows (identity round-trip for now — OrderLines come later).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Loading_an_order_restores_its_state_and_its_client()
|
public void Loading_an_order_restores_its_state_and_its_client()
|
||||||
@@ -98,7 +107,7 @@ public class AfterPersistenceTests
|
|||||||
Assert.NotNull(loaded);
|
Assert.NotNull(loaded);
|
||||||
Assert.Equal(order.Id, loaded!.Id);
|
Assert.Equal(order.Id, loaded!.Id);
|
||||||
Assert.Equal("one", loaded.Description);
|
Assert.Equal("one", loaded.Description);
|
||||||
Assert.Equal(150_00, loaded.TotalInCents.Cents);
|
Assert.Equal(150_00, loaded.TotalInCents);
|
||||||
|
|
||||||
// The graph is reassembled: the loaded order knows its client again,
|
// The graph is reassembled: the loaded order knows its client again,
|
||||||
// and the client's collection holds the loaded order.
|
// and the client's collection holds the loaded order.
|
||||||
@@ -134,53 +143,4 @@ public class AfterPersistenceTests
|
|||||||
Assert.Null(db.LoadOrder(Guid.NewGuid()));
|
Assert.Null(db.LoadOrder(Guid.NewGuid()));
|
||||||
Assert.Null(db.LoadClient(Guid.NewGuid()));
|
Assert.Null(db.LoadClient(Guid.NewGuid()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tracking: explicit Save, distinct ids; a saved object keeps no reference to the context.
|
|
||||||
[Fact]
|
|
||||||
public void DbContext_saves_plain_domain_objects_and_tracks_them()
|
|
||||||
{
|
|
||||||
var db = new DbContext();
|
|
||||||
var order = ValidOrder().WithDescription("one").Build();
|
|
||||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
|
||||||
|
|
||||||
Assert.Equal(Guid.Empty, client.Id);
|
|
||||||
db.Save(client);
|
|
||||||
db.Save(order);
|
|
||||||
|
|
||||||
Assert.NotEqual(Guid.Empty, client.Id);
|
|
||||||
Assert.NotEqual(Guid.Empty, order.Id);
|
|
||||||
Assert.NotEqual(client.Id, order.Id);
|
|
||||||
Assert.True(db.IsTracked(client));
|
|
||||||
Assert.True(db.IsTracked(order));
|
|
||||||
Assert.Equal(2, db.Tracked.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Saving_twice_does_not_reassign_the_id()
|
|
||||||
{
|
|
||||||
var db = new DbContext();
|
|
||||||
var client = ValidClient().WithName("Acme").Build();
|
|
||||||
db.Save(client);
|
|
||||||
var id = client.Id;
|
|
||||||
|
|
||||||
db.Save(client);
|
|
||||||
|
|
||||||
Assert.Equal(id, client.Id);
|
|
||||||
Assert.Single(db.Tracked);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void A_saved_domain_object_keeps_no_reference_to_the_context()
|
|
||||||
{
|
|
||||||
var db = new DbContext();
|
|
||||||
var client = ValidClient().WithName("Acme").Build();
|
|
||||||
db.Save(client);
|
|
||||||
|
|
||||||
// Saved (the id was assigned by the context) ...
|
|
||||||
Assert.NotEqual(Guid.Empty, client.Id);
|
|
||||||
Assert.True(db.IsTracked(client));
|
|
||||||
|
|
||||||
// No way back to the context through the domain object (see DomainStaysPlainTests): the only "receipt" is the plain Id value.
|
|
||||||
Assert.Equal(db.Tracked.Single(t => t.Id == client.Id).Id, client.Id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
using After;
|
||||||
|
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||||
|
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
{
|
||||||
|
private const BindingFlags AllMembers =
|
||||||
|
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (1) The leak is gone: Client/Order do NOT derive from DbBase and expose
|
||||||
|
// no DbContext-typed property or field (reflection).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Theory]
|
||||||
|
[InlineData(typeof(Client))]
|
||||||
|
[InlineData(typeof(Order))]
|
||||||
|
public void Domain_objects_are_plain__no_DbBase_no_DbContext_member(Type type)
|
||||||
|
{
|
||||||
|
// Not DbBase, and not a derived type either (walk the base chain):
|
||||||
|
for (var t = type; t is not null && t != typeof(object); t = t.BaseType)
|
||||||
|
Assert.NotSame(typeof(DbBase), t);
|
||||||
|
|
||||||
|
// No DbContext-typed member, public or private:
|
||||||
|
foreach (var p in type.GetProperties(AllMembers))
|
||||||
|
Assert.True(p.PropertyType != typeof(DbContext),
|
||||||
|
$"{type.Name}.{p.Name} leaks a DbContext-typed property");
|
||||||
|
foreach (var f in type.GetFields(AllMembers))
|
||||||
|
Assert.True(f.FieldType != typeof(DbContext),
|
||||||
|
$"{type.Name}.{f.Name} leaks a DbContext-typed field");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (2) Mappers map all scalar fields, in both directions.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void ToClientDto_maps_all_scalar_fields()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var client = ValidClient().WithId(id).WithName("Acme").Build();
|
||||||
|
|
||||||
|
var dto = Mappers.ToClientDto(client);
|
||||||
|
|
||||||
|
Assert.Equal(id, dto.Id);
|
||||||
|
Assert.Equal("Acme", dto.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToOrderDto_maps_all_scalar_fields()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
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]
|
||||||
|
public void FromClientDto_maps_all_scalar_fields()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var dto = new ClientDto { Id = id, Name = "Acme" };
|
||||||
|
|
||||||
|
var client = Mappers.FromClientDto(dto);
|
||||||
|
|
||||||
|
Assert.Equal(id, client.Id);
|
||||||
|
Assert.Equal("Acme", client.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromOrderDto_maps_all_scalar_fields()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (3) Mappers map the relations: Client.Orders -> ClientDto.Orders, and the
|
||||||
|
// Order.Client / OrderDto.ClientDto back-references stay consistent.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
// The order is mapped with the client's data:
|
||||||
|
Assert.Equal(order.Id, orderDto.Id);
|
||||||
|
Assert.Equal(order.Description, orderDto.Description);
|
||||||
|
|
||||||
|
// The back-reference cycle is resolved to the SAME dto reference:
|
||||||
|
Assert.Same(dto, orderDto.ClientDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromClientDto_wires_the_order_client_back_reference()
|
||||||
|
{
|
||||||
|
var orderDto = new OrderDto { Description = "one" };
|
||||||
|
var dto = new ClientDto { Name = "Acme", Orders = new[] { orderDto } };
|
||||||
|
|
||||||
|
var client = Mappers.FromClientDto(dto);
|
||||||
|
var order = Assert.Single(client.Orders);
|
||||||
|
|
||||||
|
Assert.Equal(orderDto.Description, order.Description);
|
||||||
|
// Navigation fix-up: the reconstructed order points at the client:
|
||||||
|
Assert.Same(client, order.Client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (4) Round-trips preserve all state (compared by values, not reference).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Domain_round_trip_preserves_all_state()
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
|
||||||
|
Assert.Equal(client.Id, roundTrip.Id);
|
||||||
|
Assert.Equal(client.Name, roundTrip.Name);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dto_round_trip_preserves_all_state()
|
||||||
|
{
|
||||||
|
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];
|
||||||
|
|
||||||
|
var roundTrip = Mappers.ToClientDto(Mappers.FromClientDto(dto));
|
||||||
|
|
||||||
|
Assert.Equal(dto.Id, roundTrip.Id);
|
||||||
|
Assert.Equal(dto.Name, roundTrip.Name);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (5) Mappers do not mutate their inputs (snapshot fields, map, compare).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void ToClientDto_does_not_mutate_the_domain()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
Mappers.ToOrderDto(order);
|
||||||
|
|
||||||
|
Assert.Equal("Acme", client.Name);
|
||||||
|
Assert.Equal(ordersCount, client.Orders.Count);
|
||||||
|
Assert.Same(client, order.Client); // back-reference untouched
|
||||||
|
Assert.Equal("one", order.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromClientDto_does_not_mutate_the_dto()
|
||||||
|
{
|
||||||
|
var orderDto = new OrderDto { Description = "one" };
|
||||||
|
var dto = new ClientDto { Id = Guid.NewGuid(), Name = "Acme", Orders = new[] { orderDto } };
|
||||||
|
|
||||||
|
Mappers.FromClientDto(dto);
|
||||||
|
Mappers.FromOrderDto(orderDto);
|
||||||
|
|
||||||
|
Assert.Equal("Acme", dto.Name);
|
||||||
|
Assert.Same(orderDto, Assert.Single(dto.Orders)); // collection untouched
|
||||||
|
Assert.Null(orderDto.ClientDto); // back-reference untouched
|
||||||
|
Assert.Equal("one", orderDto.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (6) The after WebApp consumes only DTOs: its public API references no
|
||||||
|
// domain (Client/Order) or persistence (DbContext/DbBase) type.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
private static readonly Type[] BannedTypes =
|
||||||
|
[typeof(Client), typeof(Order), typeof(DbContext), typeof(DbBase)];
|
||||||
|
|
||||||
|
private static IEnumerable<Type> TypeAndArguments(Type t)
|
||||||
|
{
|
||||||
|
yield return t;
|
||||||
|
if (t.IsGenericType)
|
||||||
|
foreach (var arg in t.GetGenericArguments())
|
||||||
|
foreach (var nested in TypeAndArguments(arg))
|
||||||
|
yield return nested;
|
||||||
|
if (t.IsArray && t.GetElementType() is { } elem)
|
||||||
|
foreach (var nested in TypeAndArguments(elem))
|
||||||
|
yield return nested;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void After_WebApp_public_api_references_no_domain_or_db_types()
|
||||||
|
{
|
||||||
|
var app = typeof(WebApp);
|
||||||
|
var banned = BannedTypes.ToList();
|
||||||
|
|
||||||
|
foreach (var m in app.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
var signature = m.GetParameters()
|
||||||
|
.Select(p => p.ParameterType)
|
||||||
|
.Append(m.ReturnType)
|
||||||
|
.ToList();
|
||||||
|
foreach (var t in signature.SelectMany(TypeAndArguments))
|
||||||
|
Assert.False(banned.Contains(t),
|
||||||
|
$"WebApp.{m.Name}({string.Join(", ", signature)}) references banned type {t}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var p in app.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
||||||
|
foreach (var t in TypeAndArguments(p.PropertyType))
|
||||||
|
Assert.False(banned.Contains(t),
|
||||||
|
$"WebApp.{p.Name} references banned type {t}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void After_WebApp_consumes_dtos()
|
||||||
|
{
|
||||||
|
var app = new WebApp();
|
||||||
|
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));
|
||||||
|
|
||||||
|
Assert.Contains("Acme", view);
|
||||||
|
Assert.Contains("order one", view);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (7) Persistence still works through the fake DbContext in the after
|
||||||
|
// world: explicit Save, distinct ids, tracking — and even a saved
|
||||||
|
// domain object keeps no back-reference to the context.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void DbContext_saves_plain_domain_objects_and_tracks_them()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var order = ValidOrder().WithDescription("one").Build();
|
||||||
|
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||||
|
|
||||||
|
Assert.Equal(Guid.Empty, client.Id);
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
Assert.NotEqual(Guid.Empty, client.Id);
|
||||||
|
Assert.NotEqual(Guid.Empty, order.Id);
|
||||||
|
Assert.NotEqual(client.Id, order.Id);
|
||||||
|
Assert.True(db.IsTracked(client));
|
||||||
|
Assert.True(db.IsTracked(order));
|
||||||
|
Assert.Equal(2, db.Tracked.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Saving_twice_does_not_reassign_the_id()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var client = ValidClient().WithName("Acme").Build();
|
||||||
|
db.Save(client);
|
||||||
|
var id = client.Id;
|
||||||
|
|
||||||
|
db.Save(client);
|
||||||
|
|
||||||
|
Assert.Equal(id, client.Id);
|
||||||
|
Assert.Single(db.Tracked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_saved_domain_object_keeps_no_reference_to_the_context()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var client = ValidClient().WithName("Acme").Build();
|
||||||
|
db.Save(client);
|
||||||
|
|
||||||
|
// Saved (the id was assigned by the context) ...
|
||||||
|
Assert.NotEqual(Guid.Empty, client.Id);
|
||||||
|
Assert.True(db.IsTracked(client));
|
||||||
|
|
||||||
|
// ... but there is no way back to the context through the domain
|
||||||
|
// object: no DbContext-typed member exists on it (test 1), so the
|
||||||
|
// only "receipt" is the plain Id value.
|
||||||
|
var dto = Mappers.ToClientDto(client);
|
||||||
|
Assert.Equal(client.Id, dto.Id); // the id flows on into the DTO
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Before\Before.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\After\After.csproj" />
|
||||||
|
<ProjectReference Include="..\..\tools\ExtractMethod\ExtractMethod.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- The ExtractMethod fixtures are DATA for the Roslyn tool, not source:
|
||||||
|
they are excluded from this project's compilation (the scratch
|
||||||
|
compilation gives the tool the exact checked-in text) and copied to
|
||||||
|
the output dir so tests can find them by path. -->
|
||||||
|
<Compile Remove="ExtractMethod\Fixtures\**\*.cs" />
|
||||||
|
<!-- Rebase the copy target to Fixtures/ in the output dir: the tool's
|
||||||
|
apphost (the Executable 'ExtractMethod') is copied here too, and a
|
||||||
|
folder named ExtractMethod/ would collide with that file. -->
|
||||||
|
<Content Include="ExtractMethod\Fixtures\**\*.cs" Link="Fixtures\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
using Before;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the BEFORE situation (Yak 01): DB-backed domain classes that
|
||||||
|
/// inherit <see cref="DbBase"/> and leak their <see cref="DbContext"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class BeforeTests
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (1) Inheritance: the domain classes ARE DB-backed (they derive from
|
||||||
|
// DbBase). This is the "before" shape Yak 02 will undo.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Client_and_Order_derive_from_DbBase()
|
||||||
|
{
|
||||||
|
Assert.True(typeof(DbBase).IsAssignableFrom(typeof(Client)));
|
||||||
|
Assert.True(typeof(DbBase).IsAssignableFrom(typeof(Order)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (2) Fresh entities are unsaved: their Id is still Guid.Empty.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Theory]
|
||||||
|
[InlineData(typeof(Client))]
|
||||||
|
[InlineData(typeof(Order))]
|
||||||
|
public void New_entities_have_an_empty_id(Type type)
|
||||||
|
{
|
||||||
|
var entity = (DbBase)Activator.CreateInstance(type)!;
|
||||||
|
Assert.Equal(Guid.Empty, entity.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (3) Save() fakes EF's SaveChanges: it assigns fresh, distinct Guids to
|
||||||
|
// entities that do not have one yet.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Save_assigns_fresh_distinct_ids()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var acme = new Client { Name = "Acme" };
|
||||||
|
var globex = new Client { Name = "Globex" };
|
||||||
|
db.Attach(acme);
|
||||||
|
db.Attach(globex);
|
||||||
|
|
||||||
|
Assert.Equal(2, db.Save());
|
||||||
|
|
||||||
|
Assert.NotEqual(Guid.Empty, acme.Id);
|
||||||
|
Assert.NotEqual(Guid.Empty, globex.Id);
|
||||||
|
Assert.NotEqual(acme.Id, globex.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (4) A saved entity is registered with its context, reachable via the
|
||||||
|
// active-record back-reference.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Saved_entity_is_registered_with_its_context()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var client = new Client { Name = "Acme" };
|
||||||
|
db.Attach(client);
|
||||||
|
db.Save();
|
||||||
|
|
||||||
|
Assert.Same(db, client.DbContext);
|
||||||
|
Assert.True(db.IsTracked(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (5) Navigation fix-up: adding an order to client.Orders wires the
|
||||||
|
// order.Client back-reference.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Adding_an_order_sets_the_client_back_reference()
|
||||||
|
{
|
||||||
|
var client = new Client { Name = "Acme" };
|
||||||
|
var order = new Order { Description = "first order" };
|
||||||
|
|
||||||
|
client.Orders.Add(order);
|
||||||
|
|
||||||
|
Assert.Same(client, order.Client);
|
||||||
|
Assert.Same(order, client.Orders[0]);
|
||||||
|
Assert.Single(client.Orders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (6) THE LEAK: from a plain Client you can reach its DbContext.
|
||||||
|
// This is exactly what the "after" situation removes: a ClientDto has
|
||||||
|
// no DbContext to reach, so a consumer can never touch the persistence
|
||||||
|
// layer through it.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void A_client_can_reach_its_DbContext__the_leak()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var client = new Client { Name = "Acme" };
|
||||||
|
db.Attach(client);
|
||||||
|
db.Save();
|
||||||
|
|
||||||
|
// Reaching the persistence layer *through* the domain object:
|
||||||
|
Assert.NotNull(client.DbContext);
|
||||||
|
Assert.Same(db, client.DbContext);
|
||||||
|
Assert.True(client.DbContext!.IsTracked(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (7) The WebApp consumes the domain classes directly — including a method
|
||||||
|
// that rides the leak.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void WebApp_works_on_the_domain_classes_directly()
|
||||||
|
{
|
||||||
|
var app = new WebApp();
|
||||||
|
var db = new DbContext();
|
||||||
|
|
||||||
|
var client = new Client { Name = "Acme" };
|
||||||
|
var order = new Order { Description = "order one" };
|
||||||
|
client.Orders.Add(order);
|
||||||
|
db.Attach(client);
|
||||||
|
db.Attach(order);
|
||||||
|
db.Save();
|
||||||
|
|
||||||
|
// ShowClient consumes Client and its Orders directly:
|
||||||
|
var view = app.ShowClient(client);
|
||||||
|
Assert.Contains("Acme", view);
|
||||||
|
Assert.Contains("order one", view);
|
||||||
|
|
||||||
|
// IsPersisted rides the leak (client.DbContext) — and it is true here
|
||||||
|
// because the client was saved through the context:
|
||||||
|
Assert.True(app.IsPersisted(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A companion check: a bare client has no context until saved.
|
||||||
|
[Fact]
|
||||||
|
public void A_bare_client_has_no_context_until_saved()
|
||||||
|
{
|
||||||
|
var app = new WebApp();
|
||||||
|
var client = new Client { Name = "Nobody" };
|
||||||
|
|
||||||
|
Assert.Null(client.DbContext);
|
||||||
|
Assert.False(app.IsPersisted(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// (8) Static Context (Singleton): DbBase exposes a class-level
|
||||||
|
// shared DbContext. Setting it makes the context accessible
|
||||||
|
// from any entity via its base type.
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void DbBase_has_a_static_Context_singleton()
|
||||||
|
{
|
||||||
|
Assert.Null(DbBase.Context); // fresh — not set yet
|
||||||
|
|
||||||
|
var db = new DbContext();
|
||||||
|
DbBase.Context = db;
|
||||||
|
|
||||||
|
Assert.Same(db, DbBase.Context);
|
||||||
|
|
||||||
|
DbBase.Context = null; // cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (9) ActiveRecord finder: DbBase.Find<T> walks the context's tracked
|
||||||
|
// entities, looks for the first whose runtime type matches T,
|
||||||
|
// and returns it when <paramref name="predicate"/> matches.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void DbBase_Find_finds_matching_entity()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var acme = new Client { Name = "Acme" };
|
||||||
|
var globex = new Client { Name = "Globex" };
|
||||||
|
db.Attach(acme);
|
||||||
|
db.Attach(globex);
|
||||||
|
|
||||||
|
var result = DbBase.Find<Client>(c => c.Name == "Globex", db);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Same(globex, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (10) DbBase.Find returns null when no entity of type T satisfies
|
||||||
|
// the predicate.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void DbBase_Find_returns_null_when_no_match()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
db.Attach(new Client { Name = "Acme" });
|
||||||
|
|
||||||
|
var result = DbBase.Find<Client>(c => c.Name == "Nobody", db);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (11) DbBase.Find filters by runtime type — an Order attached to the
|
||||||
|
// context does NOT match a Client predicate.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void DbBase_Find_ignores_non_matching_types()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
db.Attach(new Order { Description = "test" });
|
||||||
|
|
||||||
|
var result = DbBase.Find<Client>(_ => true, db);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (12) DbBase.Find returns the FIRST matching entity only.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void DbBase_Find_returns_first_match()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var first = new Client { Name = "SameName" };
|
||||||
|
var second = new Client { Name = "SameName" };
|
||||||
|
db.Attach(first);
|
||||||
|
db.Attach(second);
|
||||||
|
|
||||||
|
var result = DbBase.Find<Client>(c => c.Name == "SameName", db);
|
||||||
|
|
||||||
|
Assert.Same(first, result); // first one inserted wins
|
||||||
|
Assert.NotSame(second, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (13) DbBase.Find handles a null context gracefully — with no explicit
|
||||||
|
// context AND no singleton configured, it returns default (null).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void DbBase_Find_with_null_context_returns_default()
|
||||||
|
{
|
||||||
|
var result = DbBase.Find<Client>(_ => true, null);
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (13b) When no explicit context is passed, Find falls back to the
|
||||||
|
// DbBase.Context singleton instead of returning null.
|
||||||
|
[Fact]
|
||||||
|
public void DbBase_Find_falls_back_to_Context_singleton_when_db_is_null()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var acme = new Client { Name = "SingletonCo" };
|
||||||
|
db.Attach(acme);
|
||||||
|
|
||||||
|
DbBase.Context = db;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = DbBase.Find<Client>(c => c.Name == "SingletonCo", null);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Same(acme, result);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DbBase.Context = null; // cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (14) Client.FindByName delegates to DbBase.Find and works correctly.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Client_FindByName_finds_by_name()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var acme = new Client { Name = "Acme Corp" };
|
||||||
|
db.Attach(acme);
|
||||||
|
|
||||||
|
var result = Client.FindByName("Acme Corp", db);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Same(acme, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (15) Client.FindByName returns null when the name does not match.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Client_FindByName_returns_null_when_not_found()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
db.Attach(new Client { Name = "Acme Corp" });
|
||||||
|
|
||||||
|
var result = Client.FindByName("Nobody", db);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (15b) FindByName with no context argument uses the Context singleton.
|
||||||
|
[Fact]
|
||||||
|
public void Client_FindByName_uses_Context_singleton_when_db_omitted()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var acme = new Client { Name = "Acme Corp" };
|
||||||
|
db.Attach(acme);
|
||||||
|
|
||||||
|
DbBase.Context = db;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = Client.FindByName("Acme Corp");
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Same(acme, result);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DbBase.Context = null; // cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (16) The class-level DbBase.Context singleton can be used as the
|
||||||
|
// implicit context source for Find operations — the classic
|
||||||
|
// ActiveRecord pattern where any entity method reaches the shared
|
||||||
|
// context without parameter passing.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void DbBase_Context_singleton_is_used_in_Find()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var targeted = new Client { Name = "TargetCo" };
|
||||||
|
db.Attach(targeted);
|
||||||
|
|
||||||
|
DbBase.Context = db;
|
||||||
|
|
||||||
|
// Query using the singleton instead of passing the context:
|
||||||
|
// (In practice, callers often do this to avoid threading db through
|
||||||
|
// every call — the whole point of the ActiveRecord leak.)
|
||||||
|
var queryResult = DbBase.Find<Client>(c => c.Name == "TargetCo", DbBase.Context);
|
||||||
|
|
||||||
|
Assert.NotNull(queryResult);
|
||||||
|
Assert.Same(targeted, queryResult);
|
||||||
|
|
||||||
|
DbBase.Context = null; // cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (17) DbBase.FindRequired<T> finds the matching entity — same result as Find<T>.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void DbBase_FindRequired_T_finds_matching_entity()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var acme = new Client { Name = "Acme" };
|
||||||
|
var globex = new Client { Name = "Globex" };
|
||||||
|
db.Attach(acme);
|
||||||
|
db.Attach(globex);
|
||||||
|
|
||||||
|
var result = DbBase.FindRequired<Client>(c => c.Name == "Globex", db, "name == Globex");
|
||||||
|
|
||||||
|
Assert.Same(globex, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (18) DbBase.FindRequired<T> throws ObjectNotFoundException when no match
|
||||||
|
// is found (the domain-specific lookup miss, not a BCL exception).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void DbBase_FindRequired_T_throws_when_no_match()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
db.Attach(new Client { Name = "Acme" });
|
||||||
|
|
||||||
|
var ex = Assert.Throws<ObjectNotFoundException>(() =>
|
||||||
|
DbBase.FindRequired<Client>(c => c.Name == "Nobody", db, "name == Nobody"));
|
||||||
|
|
||||||
|
Assert.Contains("Nobody", ex.Message);
|
||||||
|
Assert.Contains("no matching Client found", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (19) DbBase.FindRequired<T> throws with a useful message when no context is
|
||||||
|
// configured (both explicit null and singleton null).
|
||||||
|
//
|
||||||
|
// Deliberately InvalidOperationException, not ObjectNotFoundException:
|
||||||
|
// a missing context is a configuration error, not a lookup miss.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void DbBase_FindRequired_T_throws_when_no_context_configured()
|
||||||
|
{
|
||||||
|
DbBase.Context = null;
|
||||||
|
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
DbBase.FindRequired<Client>(_ => true, null, "true"));
|
||||||
|
|
||||||
|
Assert.Contains("no DbContext configured", ex.Message);
|
||||||
|
Assert.Contains("DbBase.Context", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (20) Client.FindByNameRequired finds the matching client by name.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Client_FindByNameRequired_finds_by_name()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var acme = new Client { Name = "Acme Corp" };
|
||||||
|
db.Attach(acme);
|
||||||
|
|
||||||
|
var result = Client.FindByNameRequired("Acme Corp", db);
|
||||||
|
|
||||||
|
Assert.Same(acme, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (21) Client.FindByNameRequired throws ObjectNotFoundException when no
|
||||||
|
// match — message includes name.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Client_FindByNameRequired_throws_with_name_when_not_found()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
db.Attach(new Client { Name = "Acme Corp" });
|
||||||
|
|
||||||
|
var ex = Assert.Throws<ObjectNotFoundException>(() =>
|
||||||
|
Client.FindByNameRequired("Nobody", db));
|
||||||
|
|
||||||
|
Assert.Contains("Nobody", ex.Message);
|
||||||
|
Assert.Contains("no matching Client found", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (22) Client.FindByNameRequired uses Context singleton when no context arg.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Client_FindByNameRequired_uses_Context_singleton()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var acme = new Client { Name = "SingletonCo" };
|
||||||
|
db.Attach(acme);
|
||||||
|
|
||||||
|
DbBase.Context = db;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = Client.FindByNameRequired("SingletonCo");
|
||||||
|
Assert.Same(acme, result);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DbBase.Context = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (23) Client.FindByNameRequired throws with a useful message when the
|
||||||
|
// singleton is not configured (no arg, no singleton).
|
||||||
|
// Stays InvalidOperationException: configuration error, not a miss.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Client_FindByNameRequired_throws_with_context_hint_when_singleton_null()
|
||||||
|
{
|
||||||
|
DbBase.Context = null;
|
||||||
|
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
Client.FindByNameRequired("SomeBody"));
|
||||||
|
|
||||||
|
Assert.Contains("no DbContext configured", ex.Message);
|
||||||
|
Assert.Contains("DbBase.Context", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,59 @@
|
|||||||
|
using After;
|
||||||
|
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||||
|
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EXERCISE — the mapping from a <see cref="DiscountedOrder"/> onto an
|
||||||
|
/// <see cref="Order"/> is NOT one-on-one. (This is the solution participants
|
||||||
|
/// arrive at from the stub in EXERCISE.md.)
|
||||||
|
/// </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);
|
||||||
|
|
||||||
|
// Correlate: the discounted result belongs to the order carrying this id.
|
||||||
|
Assert.Equal(order.Id, discounted.OrderId);
|
||||||
|
|
||||||
|
// Copy the discounted total onto the order. The discount percentage is
|
||||||
|
// deliberately NOT copied — it is derivable from the two totals.
|
||||||
|
order.DiscountedTotalInCents = discounted.DiscountedTotalInCents;
|
||||||
|
|
||||||
|
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);
|
||||||
|
Assert.Equal(order.Id, discounted.OrderId);
|
||||||
|
order.DiscountedTotalInCents = discounted.DiscountedTotalInCents;
|
||||||
|
|
||||||
|
// Save the updated order: the context refreshes the stored row in place.
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
// The persisted record keeps the ORIGINAL total: the database knows
|
||||||
|
// nothing about discounts — derived data lives in the domain only.
|
||||||
|
var row = db.Find<OrderData>(candidate => candidate.Id == discounted.OrderId)!;
|
||||||
|
Assert.Equal(150_00, row.TotalInCents);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for yak em 02: the data-flow classifier (buckets: params / returns /
|
||||||
|
/// locals) over the checked-in Demo.cs fixture. Line anchors are the exact
|
||||||
|
/// fixture lines pinned by em 01 (see Demo.cs header comment):
|
||||||
|
/// ScoreReads body 56..58, Summarize body 68..76 (for-loop 69..72),
|
||||||
|
/// Heaviest body 87..93.
|
||||||
|
/// </summary>
|
||||||
|
public class DataFlowClassificationTests
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// in-param bucket (ScoreReads, full body 56..58).
|
||||||
|
// ReadInside = {_seed→field, seed, score, bonus}; the classifier must
|
||||||
|
// report the reads that are locals/params — seed/score/bonus — as in-
|
||||||
|
// params, and must NOT leak `this` (the implicit this-read behind the
|
||||||
|
// _seed field access; filtered via IParameterSymbol.IsThis) into params,
|
||||||
|
// nor the _seed field itself (decision #6 keeps fields for em 03).
|
||||||
|
// WrittenInside = {seed, score}; no tail, trailing return is an expression
|
||||||
|
// (score + bonus) which v1 cannot name -> no returns, both written vars
|
||||||
|
// are scratch locals.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void ScoreReads_full_body_reads_report_three_in_params_and_no_returns()
|
||||||
|
{
|
||||||
|
var suggestion = ResolveAndClassify(56, 58);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "bonus", "score", "seed" }, suggestion.Params.Select(p => p.Name));
|
||||||
|
// None is reassigned inside (declaration initializers are not write-backs) -> no ref.
|
||||||
|
Assert.All(suggestion.Params, p => Assert.False(p.ByRef));
|
||||||
|
Assert.Empty(suggestion.Returns);
|
||||||
|
// v1 reports the read locals ALSO as scratch locals (decision #6 over-
|
||||||
|
// reports local reads); em 03 dedupes them into the signature.
|
||||||
|
Assert.Equal(new[] { "score", "seed" }, suggestion.Locals);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// multi-statement range case (Summarize 68..72: LocalDeclarationStatement
|
||||||
|
// + ForStatement): the resolution, the selection data flow AND the tail
|
||||||
|
// data flow all go through the two-argument overload with Succeeded=true
|
||||||
|
// (the synth-block trick from parent decision #3 is the DEAD path — see
|
||||||
|
// Test for it below). Buckets: total written inside + read at line 74 in
|
||||||
|
// the tail -> return; for-var i never read after -> local.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Summarize_multi_statement_range_flows_via_two_argument_overload_and_buckets_total_return_i_local()
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
var report = SelectionResolver.Resolve(tree, 68, 72);
|
||||||
|
Assert.True(report.Succeeded, report.Error);
|
||||||
|
|
||||||
|
var selectionFlow = DataFlowClassifier.AnalyzeSelectionFlow(model, report);
|
||||||
|
Assert.True(selectionFlow.Succeeded, "two-argument AnalyzeDataFlow must succeed on a multi-statement selection");
|
||||||
|
|
||||||
|
var tailFlow = DataFlowClassifier.AnalyzeTailFlow(model, report);
|
||||||
|
Assert.NotNull(tailFlow);
|
||||||
|
Assert.True(tailFlow.Succeeded, "two-argument AnalyzeDataFlow must succeed on the (multi-statement) tail region");
|
||||||
|
|
||||||
|
var suggestion = DataFlowClassifier.Classify(model, report);
|
||||||
|
Assert.Equal(new[] { "total" }, suggestion.Returns.Select(r => r.Name));
|
||||||
|
Assert.Equal(new[] { "int" }, suggestion.Returns.Select(r => r.Type));
|
||||||
|
Assert.Equal(new[] { "i" }, suggestion.Locals);
|
||||||
|
|
||||||
|
// limit is read in the for-header (68..72) — a plain by-value in-param.
|
||||||
|
var limit = Assert.Single(suggestion.Params.Where(p => p.Name == "limit"));
|
||||||
|
Assert.False(limit.ByRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// return + ref-param buckets (Summarize, full body 68..76).
|
||||||
|
// Returns: the tail is empty, so the tail-flow rule yields nothing; the
|
||||||
|
// selection ends with `return message;` -> message is the trailing-return
|
||||||
|
// candidate (parent spec). Locals: everything else written inside:
|
||||||
|
// {i, scaled, total}.
|
||||||
|
// Params (all ReadInside locals/params): the differential ref check —
|
||||||
|
// total is REASSIGNED inside (total += i at line 71) -> ByRef; scaled and
|
||||||
|
// limit are never reassigned (declaration write only) -> plain in-params.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Summarize_full_body_returns_trailing_message_and_flags_reassigned_total_as_ref()
|
||||||
|
{
|
||||||
|
var suggestion = ResolveAndClassify(68, 76);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "message" }, suggestion.Returns.Select(r => r.Name));
|
||||||
|
Assert.Equal(new[] { "i", "scaled", "total" }, suggestion.Locals);
|
||||||
|
|
||||||
|
var total = Assert.Single(suggestion.Params.Where(p => p.Name == "total"));
|
||||||
|
Assert.True(total.ByRef, "total is reassigned inside (total += i) -> the extraction owes a write-back");
|
||||||
|
|
||||||
|
var scaled = Assert.Single(suggestion.Params.Where(p => p.Name == "scaled"));
|
||||||
|
Assert.False(scaled.ByRef, "scaled is only declared inside, never reassigned");
|
||||||
|
|
||||||
|
var limit = Assert.Single(suggestion.Params.Where(p => p.Name == "limit"));
|
||||||
|
Assert.False(limit.ByRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// return + extract-first isolation (Heaviest, full body 87..93).
|
||||||
|
// Returns: `return best;` is the last statement -> best (trailing-return
|
||||||
|
// rule again). Written inside but never read after: i -> local.
|
||||||
|
// Params: reads are best/count/i/widgets; best and i are reassigned
|
||||||
|
// inside -> ref. Nothing extract-first (widgets[i] indexer, Bigger
|
||||||
|
// invocation — em 03 territory) may leak into any bucket: the params list
|
||||||
|
// is exactly the four variable reads.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Heaviest_full_body_returns_best_locals_i_and_leaks_no_extract_first_names()
|
||||||
|
{
|
||||||
|
var suggestion = ResolveAndClassify(87, 93);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "best" }, suggestion.Returns.Select(r => r.Name));
|
||||||
|
Assert.Equal(new[] { "i" }, suggestion.Locals);
|
||||||
|
Assert.Equal(new[] { "best", "count", "i", "widgets" }, suggestion.Params.Select(p => p.Name));
|
||||||
|
Assert.Equal(new[] { true, false, true, false }, suggestion.Params.Select(p => p.ByRef));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// single-statement fallback (ScoreReads, line 58 alone: `return ...;`).
|
||||||
|
// The one-argument AnalyzeDataFlow overload is the live path for a single
|
||||||
|
// statement. The trailing return is `score + bonus` — an expression, not
|
||||||
|
// a simple name — so v1 reports no return candidate (composite return
|
||||||
|
// expressions are extract-first shape for em 03) and no locals.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Single_statement_selection_uses_one_argument_overload_and_composite_trailing_return_is_not_nameable()
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
var report = SelectionResolver.Resolve(tree, 58, 58);
|
||||||
|
Assert.True(report.Succeeded, report.Error);
|
||||||
|
|
||||||
|
Assert.True(DataFlowClassifier.AnalyzeSelectionFlow(model, report).Succeeded);
|
||||||
|
|
||||||
|
var suggestion = DataFlowClassifier.Classify(model, report);
|
||||||
|
Assert.Equal(new[] { "bonus", "score" }, suggestion.Params.Select(p => p.Name));
|
||||||
|
Assert.All(suggestion.Params, p => Assert.False(p.ByRef));
|
||||||
|
Assert.Empty(suggestion.Returns);
|
||||||
|
Assert.Empty(suggestion.Locals);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// DEAD PATH (parent decision #3, pinned so the choice is self-explanatory
|
||||||
|
// to future readers): a synthetic BlockSyntax re-parents tree nodes that
|
||||||
|
// are not in the tree, and AnalyzeDataFlow refuses with
|
||||||
|
// ArgumentException("statements not within tree"). That is why the live
|
||||||
|
// path uses the two-argument overload (both endpoints are real tree
|
||||||
|
// nodes) — which also keeps symbol identity across selection and tail
|
||||||
|
// analyses, the prerequisite for the written-inside ∩ read-after
|
||||||
|
// intersection of the return bucket.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Synthetic_block_data_flow_throws_not_within_tree_so_the_two_argument_overload_stays_live()
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
|
||||||
|
var summarize = tree.GetRoot().DescendantNodes()
|
||||||
|
.OfType<MethodDeclarationSyntax>()
|
||||||
|
.Single(m => m.Identifier.ValueText == "Summarize");
|
||||||
|
var statements = summarize.Body!.Statements;
|
||||||
|
|
||||||
|
// Same node instances, new (synthetic) parent — this is the shape
|
||||||
|
// parent decision #3 proposed for multi-statement ranges and tails.
|
||||||
|
var syntheticBlock = SyntaxFactory.Block(statements[2], statements[3], statements[4]);
|
||||||
|
|
||||||
|
var ex = Assert.Throws<ArgumentException>(() => model.AnalyzeDataFlow(syntheticBlock));
|
||||||
|
Assert.Contains("not within tree", ex.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExtractionSuggestion ResolveAndClassify(int startLine, int endLine)
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
|
||||||
|
var report = SelectionResolver.Resolve(tree, startLine, endLine);
|
||||||
|
Assert.True(report.Succeeded, report.Error);
|
||||||
|
|
||||||
|
return DataFlowClassifier.Classify(model, report);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared entry point for the ExtractMethod tests: the checked-in Demo.cs
|
||||||
|
/// fixture is DATA (excluded from this project's compilation, copied to the
|
||||||
|
/// output dir) and must ALWAYS be loaded through the tool's own
|
||||||
|
/// <see cref="CompilationLoader"/> so tests exercise the exact parse +
|
||||||
|
/// scratch-compilation path the CLI uses.
|
||||||
|
/// </summary>
|
||||||
|
internal static class DemoFixture
|
||||||
|
{
|
||||||
|
/// <summary>Where the fixture lands after the csproj copies it (link Fixtures/).</summary>
|
||||||
|
public static string Path =>
|
||||||
|
System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "Demo.cs");
|
||||||
|
|
||||||
|
/// <summary>Parse + scratch-compile the fixture once per caller.</summary>
|
||||||
|
public static (SyntaxTree Tree, CSharpCompilation Compilation) Load()
|
||||||
|
{
|
||||||
|
if (!File.Exists(Path))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"fixture missing at {Path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var tree = CompilationLoader.ParseFile(Path);
|
||||||
|
var compilation = CompilationLoader.CreateCompilation(tree, "DemoFixture");
|
||||||
|
return (tree, compilation);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for yak em 01: the scaffold (console tool) and the checked-in demo
|
||||||
|
/// fixture. The fixture is DATA (excluded from compilation, copied to the
|
||||||
|
/// output dir), and it is parsed with the tool's own <see cref="CompilationLoader"/>
|
||||||
|
/// so these tests exercise the exact loading path the CLI uses.
|
||||||
|
/// </summary>
|
||||||
|
public class DemoFixtureTests
|
||||||
|
{
|
||||||
|
private static (SyntaxTree Tree, CSharpCompilation Compilation) LoadFixture() => DemoFixture.Load();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (a) The fixture is pristine under the scratch compilation: if this
|
||||||
|
// fails, the fixture (or the TRUSTED_PLATFORM_ASSEMBLIES reference
|
||||||
|
// loading) is broken and every later yak would analyze garbage.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Demo_compiles_with_no_diagnostics()
|
||||||
|
{
|
||||||
|
var (_, compilation) = LoadFixture();
|
||||||
|
|
||||||
|
Assert.Empty(compilation.GetDiagnostics());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (b) A known line range snaps to the expected whole statements.
|
||||||
|
// Lines 68..72 of Demo.cs are `int total = 0;` followed by the whole
|
||||||
|
// for-loop (for keyword .. closing brace) inside Summarize — exactly
|
||||||
|
// the "multi-statement range incl. a for-loop" shape of the spec.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Known_range_resolves_to_expected_statements()
|
||||||
|
{
|
||||||
|
var (tree, _) = LoadFixture();
|
||||||
|
|
||||||
|
var report = SelectionResolver.Resolve(tree, 68, 72);
|
||||||
|
|
||||||
|
Assert.True(report.Succeeded, report.Error);
|
||||||
|
Assert.Equal(2, report.Count);
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { SyntaxKind.LocalDeclarationStatement, SyntaxKind.ForStatement },
|
||||||
|
report.Kinds);
|
||||||
|
Assert.Equal("Summarize", report.Method?.Identifier.ValueText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (c) The semantic model's data-flow analysis works on the fixture's
|
||||||
|
// for-loop node — the foundation every classification in yak 02
|
||||||
|
// builds on. (AnalyzeDataFlow on the loop itself walks the bound loop
|
||||||
|
// body; Succeeded == false would mean the scratch compilation or the
|
||||||
|
// selected node cannot be bound.)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Fixture_for_loop_supports_data_flow_analysis()
|
||||||
|
{
|
||||||
|
var (tree, compilation) = LoadFixture();
|
||||||
|
|
||||||
|
var forStatement = tree.GetRoot()
|
||||||
|
.DescendantNodes()
|
||||||
|
.OfType<ForStatementSyntax>()
|
||||||
|
.Single(n => EnclosingMethodName(n) == "Summarize");
|
||||||
|
|
||||||
|
var dataFlow = compilation.GetSemanticModel(tree).AnalyzeDataFlow(forStatement);
|
||||||
|
|
||||||
|
// AnalyzeDataFlow is nullable-annotated in Roslyn 5.x: assert non-null
|
||||||
|
// so the dereference below is provably safe (and test intent explicit).
|
||||||
|
Assert.NotNull(dataFlow);
|
||||||
|
Assert.True(dataFlow.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Acceptance also demands "clean error otherwise": selecting only the
|
||||||
|
// for-loop header line (line 69) splits the statement and must fail with
|
||||||
|
// a readable reason instead of a crash or a silent wrong count.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Range_ending_mid_statement_fails_cleanly()
|
||||||
|
{
|
||||||
|
var (tree, _) = LoadFixture();
|
||||||
|
|
||||||
|
var report = SelectionResolver.Resolve(tree, 69, 69);
|
||||||
|
|
||||||
|
Assert.False(report.Succeeded);
|
||||||
|
Assert.NotNull(report.Error);
|
||||||
|
Assert.Contains("no whole", report.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? EnclosingMethodName(SyntaxNode node) =>
|
||||||
|
node.AncestorsAndSelf().OfType<MethodDeclarationSyntax>().FirstOrDefault()?.Identifier.ValueText;
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for yak em 03: the extract-first scan over the checked-in Demo.cs
|
||||||
|
/// fixture. Line anchors are the exact fixture lines pinned by em 01/02:
|
||||||
|
/// ScoreReads body 56..58, Summarize body 68..76, Heaviest body 87..93,
|
||||||
|
/// RepeatReads body 105..108, Casts body 116 (the em 03 additions at the
|
||||||
|
/// end of the fixture; nothing before them may shift — see Demo.cs header).
|
||||||
|
///
|
||||||
|
/// Extract-first = read expressions that are NOT simple local/parameter
|
||||||
|
/// references (parent bucket spec): member/field/property access, element
|
||||||
|
/// access, invocations, casts — deduped by symbol + text, occurrence-
|
||||||
|
/// counted, invocations flagged ("hoisting changes eval count"), own-class
|
||||||
|
/// field/property access marked optional.
|
||||||
|
/// </summary>
|
||||||
|
public class ExtractFirstTests
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// optional marking (own-class FIELD): ScoreReads reads `_seed` once;
|
||||||
|
// `seed`, `score`, `bonus` are simple local/param names and must NOT
|
||||||
|
// appear (decision #6 puts them in the params bucket instead).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void ScoreReads_reports_own_class_field_once_and_optional()
|
||||||
|
{
|
||||||
|
var report = Compose(56, 58);
|
||||||
|
|
||||||
|
var entry = Assert.Single(report.ExtractFirst);
|
||||||
|
Assert.Equal("_seed", entry.Text);
|
||||||
|
Assert.Equal("Demo._seed", entry.SymbolDisplay);
|
||||||
|
Assert.Equal(1, entry.Occurrences);
|
||||||
|
Assert.False(entry.IsInvocation);
|
||||||
|
Assert.True(entry.Optional, "own-class field reads are optional to hoist");
|
||||||
|
Assert.Equal("field", entry.OwnMemberKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// optional marking (own-class PROPERTY): Summarize reads `Scale` once
|
||||||
|
// (line 74). The binary expressions around it (`total * Scale`,
|
||||||
|
// `"sum=" + scaled`) are composites of locals/params and literals —
|
||||||
|
// exactly the shapes the parent spec does NOT list, so they stay out.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Summarize_reports_own_class_property_once_and_optional()
|
||||||
|
{
|
||||||
|
var report = Compose(68, 76);
|
||||||
|
|
||||||
|
var entry = Assert.Single(report.ExtractFirst);
|
||||||
|
Assert.Equal("Scale", entry.Text);
|
||||||
|
Assert.Equal("Demo.Scale", entry.SymbolDisplay);
|
||||||
|
Assert.Equal(1, entry.Occurrences);
|
||||||
|
Assert.False(entry.IsInvocation);
|
||||||
|
Assert.True(entry.Optional, "own-class property reads are optional to hoist");
|
||||||
|
Assert.Equal("property", entry.OwnMemberKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// invocation flag + element access (Heaviest, full body 87..93):
|
||||||
|
// `widgets[0]` and `widgets[i]` are two DIFFERENT indexer reads (same
|
||||||
|
// indexer symbol, different text -> separate entries); the call
|
||||||
|
// `best.Bigger(widgets[i])` is flagged because hoisting it changes how
|
||||||
|
// often it is evaluated. The callee `best.Bigger` must NOT be reported
|
||||||
|
// separately (the invocation covers it), and `best`/`i`/`widgets`/
|
||||||
|
// `count` are simple local/param names — invisible to this bucket.
|
||||||
|
// None of Heaviest's candidates is own-class (Widget is a nested record,
|
||||||
|
// the indexer belongs to List<T>) -> nothing optional here.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Heaviest_flags_the_invocation_and_lists_both_indexer_reads()
|
||||||
|
{
|
||||||
|
var report = Compose(87, 93);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "widgets[0]", "best.Bigger(widgets[i])", "widgets[i]" },
|
||||||
|
report.ExtractFirst.Select(e => e.Text));
|
||||||
|
Assert.All(report.ExtractFirst, e => Assert.Equal(1, e.Occurrences));
|
||||||
|
Assert.All(report.ExtractFirst, e => Assert.False(e.Optional)); // none of Heaviest's reads is own-class
|
||||||
|
|
||||||
|
var invocation = Assert.Single(report.ExtractFirst, e => e.IsInvocation);
|
||||||
|
Assert.Equal("best.Bigger(widgets[i])", invocation.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// dedupe + occurrence count (RepeatReads, body 105..108): `_seed` is
|
||||||
|
// read TWICE (lines 105, 106) and must dedupe to ONE entry with
|
||||||
|
// Occurrences == 2. The field WRITE `_seed = a;` (line 107) is a
|
||||||
|
// non-variable assignment LHS — the reads-only scan skips it, so it
|
||||||
|
// neither adds an occurrence nor changes the count (decision 5), and
|
||||||
|
// the report must carry the decision-5 limitation note.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void RepeatReads_dedupes_the_field_read_to_count_two_and_skips_the_field_write()
|
||||||
|
{
|
||||||
|
var report = Compose(105, 108);
|
||||||
|
|
||||||
|
var entry = Assert.Single(report.ExtractFirst);
|
||||||
|
Assert.Equal("_seed", entry.Text);
|
||||||
|
Assert.Equal(2, entry.Occurrences);
|
||||||
|
Assert.True(entry.Optional);
|
||||||
|
Assert.Equal("field", entry.OwnMemberKind);
|
||||||
|
|
||||||
|
// Two notes apply to this selection: the decision-5 write limitation
|
||||||
|
// AND the composite trailing return (`return a + b;`).
|
||||||
|
Assert.Equal(2, report.Notes.Count);
|
||||||
|
Assert.Contains(report.Notes, n => n.Contains("assignment left-hand sides", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(report.Notes, n => n.Contains("composite return expression (a + b)", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// cast (Casts, body 116): `(double)total` is an extract-first candidate
|
||||||
|
// (parent spec lists casts). A conversion is not a symbol, so the entry
|
||||||
|
// keys on its text alone ("unbound" display) and carries no flags.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Casts_reports_the_cast_expression_and_the_field_read()
|
||||||
|
{
|
||||||
|
var report = Compose(116, 116);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "(double)total", "_seed" },
|
||||||
|
report.ExtractFirst.Select(e => e.Text));
|
||||||
|
|
||||||
|
var cast = report.ExtractFirst[0];
|
||||||
|
Assert.Equal(1, cast.Occurrences);
|
||||||
|
Assert.False(cast.IsInvocation);
|
||||||
|
Assert.False(cast.Optional);
|
||||||
|
Assert.Equal("<unbound>", cast.SymbolDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExtractionReport Compose(int startLine, int endLine)
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
|
||||||
|
var resolved = SelectionResolver.Resolve(tree, startLine, endLine);
|
||||||
|
Assert.True(resolved.Succeeded, resolved.Error);
|
||||||
|
|
||||||
|
return ExtractionReporter.Compose(model, resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Demo fixture for the extract-method micro-tool (tools/ExtractMethod).
|
||||||
|
//
|
||||||
|
// This file is DATA, not project source: the test project excludes it from
|
||||||
|
// compilation (see BeforeAfter.Tests.csproj) and runs Roslyn over its raw
|
||||||
|
// text, so the tool sees exactly what is checked in here. It uses explicit
|
||||||
|
// usings because the scratch compilation has no implicit usings.
|
||||||
|
//
|
||||||
|
// Bucket map (the classification yak 02 works from):
|
||||||
|
// bucket where example
|
||||||
|
// local, read-only (param) ScoreReads seed, score
|
||||||
|
// written + read-later (return) Summarize, Heaviest total, message, best
|
||||||
|
// scratch local (local) Summarize for-loop i
|
||||||
|
// enclosing-method param (param) ScoreReads, Summarize bonus, limit
|
||||||
|
// field access (extract-first) ScoreReads _seed (optional)
|
||||||
|
// property access (extract-first) Summarize Scale (optional)
|
||||||
|
// indexer access (extract-first) Heaviest widgets[i]
|
||||||
|
// method invocation (extract-first, flagged) Heaviest best.Bigger(...)
|
||||||
|
// multi-statement range incl. for Summarize total + for-loop
|
||||||
|
//
|
||||||
|
// Tests pin EXACT line numbers of the statements they select. Keep the
|
||||||
|
// formatting stable; when a line must change, update DemoFixtureTests too.
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod.Fixtures;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Small first-class citizen of the extract-method tool: every method below
|
||||||
|
/// shows one or more of the classification "buckets" the tool must report.
|
||||||
|
/// </summary>
|
||||||
|
public class Demo
|
||||||
|
{
|
||||||
|
// Own-class state: reading it from a selection is "extract-first optional"
|
||||||
|
// (a same-class extracted method can still see these fields). Initialized
|
||||||
|
// here so the fixture compiles with zero diagnostics (CS0649 otherwise).
|
||||||
|
private int _seed = 5;
|
||||||
|
|
||||||
|
// Own-class property: like a field, accessible from a same-class method,
|
||||||
|
// so hoisting it is optional in the report.
|
||||||
|
public int Scale { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>Small local type so the scratch compilation binds.</summary>
|
||||||
|
public sealed record Widget(int Weight, int Count, string Label)
|
||||||
|
{
|
||||||
|
/// <summary>Instance method used to exercise the invocation bucket.</summary>
|
||||||
|
public Widget Bigger(Widget other) =>
|
||||||
|
Weight >= other.Weight ? this : other;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket: enclosing-method parameter read (bonus => param), local
|
||||||
|
/// read-only (seed, score => param), own-class field access
|
||||||
|
/// (_seed => extract-first, optional). Ends with a return expression.
|
||||||
|
/// </summary>
|
||||||
|
public int ScoreReads(int bonus)
|
||||||
|
{
|
||||||
|
int seed = _seed;
|
||||||
|
int score = seed * 2;
|
||||||
|
return score + bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket: written + read-later locals (total, message => return),
|
||||||
|
/// scratch local (i => local), own-class property access (Scale =>
|
||||||
|
/// extract-first optional), multi-statement range incl. a for-loop.
|
||||||
|
/// </summary>
|
||||||
|
public string Summarize(int limit)
|
||||||
|
{
|
||||||
|
int total = 0;
|
||||||
|
for (int i = 0; i < limit; i++)
|
||||||
|
{
|
||||||
|
total += i;
|
||||||
|
}
|
||||||
|
|
||||||
|
int scaled = total * Scale;
|
||||||
|
string message = "sum=" + scaled;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket: indexer access (widgets[i] on List<T> => extract-first),
|
||||||
|
/// method invocation (Bigger => extract-first, flagged because hoisting
|
||||||
|
/// changes how often it is evaluated), local written + read-later
|
||||||
|
/// (best => return).
|
||||||
|
/// </summary>
|
||||||
|
public Widget Heaviest(List<Widget> widgets, int count)
|
||||||
|
{
|
||||||
|
Widget best = widgets[0];
|
||||||
|
for (int i = 1; i < count; i++)
|
||||||
|
{
|
||||||
|
best = best.Bigger(widgets[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket (em 03): the field read <c>_seed</c> appears TWICE — the
|
||||||
|
/// extract-first scan must dedupe it to one entry with an occurrence
|
||||||
|
/// count of 2. The field WRITE below (<c>_seed = a;</c>) is a
|
||||||
|
/// non-variable assignment left-hand side: the reads-only scan skips it
|
||||||
|
/// (decision 5) and the report notes the limitation.
|
||||||
|
/// </summary>
|
||||||
|
public int RepeatReads(int n)
|
||||||
|
{
|
||||||
|
int a = _seed + n;
|
||||||
|
int b = _seed * n;
|
||||||
|
_seed = a;
|
||||||
|
return a + b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bucket (em 03): a cast is an extract-first candidate too
|
||||||
|
/// (parent spec lists casts); its bound symbol is null — conversions are
|
||||||
|
/// not symbols — so the entry keys on its text alone.</summary>
|
||||||
|
public double Casts(int total)
|
||||||
|
{
|
||||||
|
return (double)total / _seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bucket (em 04 codegen): a local DECLARED BEFORE the selection
|
||||||
|
/// that the selection writes and the tail reads — the promoted signature
|
||||||
|
/// keeps it as a <c>ref</c> parameter (the write-back already flows the
|
||||||
|
/// value out, so the result is not re-returned; void) and the call site
|
||||||
|
/// is a plain call, no assignment: <c>Extract(ref acc, n);</c>.</summary>
|
||||||
|
public int Accumulate(int n)
|
||||||
|
{
|
||||||
|
int acc = 0;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
acc += i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bucket (em 04 codegen): TWO locals declared inside, both
|
||||||
|
/// written inside and read after — the promoted signature returns a
|
||||||
|
/// tuple <c>(int, int)</c> and the call site deconstructs it:
|
||||||
|
/// <c>var (x, y) = Extract(n);</c>.</summary>
|
||||||
|
public (int, int) Pairwise(int n)
|
||||||
|
{
|
||||||
|
int x = 0;
|
||||||
|
int y = 1;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
x += i;
|
||||||
|
y += i * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (x + y, x - y);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for yak em 04 (codegen): generate the refactoring from the em 03
|
||||||
|
/// suggestion — new method (promoted signature, parameter names from the
|
||||||
|
/// source symbols) + rewritten call site + transformed tree — and the
|
||||||
|
/// round-trip check (the transformed tree compiles with zero diagnostics).
|
||||||
|
///
|
||||||
|
/// Fixture line anchors: the em 01/02/03 anchors (ScoreReads 56..58,
|
||||||
|
/// Summarize 68..76 / partial 68..72, Heaviest 87..93) plus the em 04
|
||||||
|
/// additions Accumulate for-loop 127..130 and Pairwise 141..147.
|
||||||
|
/// </summary>
|
||||||
|
public class RefactoringTests
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Summarize partial (68..72): the declared-inside `total` promotes to the
|
||||||
|
// plain return of the new method; at the call site it is re-declared from
|
||||||
|
// the result. The new body gains the appended `return total;`.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Summarize_partial_selection_generates_returning_method_and_declaration_call()
|
||||||
|
{
|
||||||
|
var (method, calls, tree) = Generate(68, 72);
|
||||||
|
|
||||||
|
Assert.Equal("Extract", method.Identifier.ValueText);
|
||||||
|
Assert.Equal("int", method.ReturnType.ToString());
|
||||||
|
Assert.Equal(new[] { "int limit" }, method.ParameterList.Parameters.Select(p => p.ToString()));
|
||||||
|
|
||||||
|
var body = (BlockSyntax)method.Body!;
|
||||||
|
Assert.Equal(3, body.Statements.Count); // total declaration, for-loop, appended return
|
||||||
|
Assert.Equal("return total;", body.Statements[2].ToString());
|
||||||
|
|
||||||
|
// `total` was declared inside the selection: re-declared at the call
|
||||||
|
// site from the result.
|
||||||
|
Assert.Single(calls);
|
||||||
|
Assert.Equal("int total = Extract(limit);", calls[0].ToString());
|
||||||
|
|
||||||
|
VerifyZeroDiagnostics(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Summarize full body (68..76): the selection ends the (non-void)
|
||||||
|
// enclosing method and the trailing `return message;` is nameable — the
|
||||||
|
// call site becomes `return Extract(limit);` and NO extra return is
|
||||||
|
// appended to the new body (the existing one is kept).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Summarize_full_body_selection_becomes_return_call()
|
||||||
|
{
|
||||||
|
var (method, calls, tree) = Generate(68, 76);
|
||||||
|
|
||||||
|
Assert.Equal("Extract", method.Identifier.ValueText);
|
||||||
|
Assert.Equal("string", method.ReturnType.ToString());
|
||||||
|
Assert.Equal(new[] { "int limit" }, method.ParameterList.Parameters.Select(p => p.ToString()));
|
||||||
|
|
||||||
|
var body = (BlockSyntax)method.Body!;
|
||||||
|
Assert.Equal(5, body.Statements.Count); // total, for, scaled, message, return — unchanged
|
||||||
|
Assert.Equal("return message;", body.Statements[^1].ToString());
|
||||||
|
|
||||||
|
Assert.Single(calls);
|
||||||
|
Assert.Equal("return Extract(limit);", calls[0].ToString());
|
||||||
|
|
||||||
|
VerifyZeroDiagnostics(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Heaviest full body (87..93): the trailing `return best;` names the
|
||||||
|
// single return slot, which matches the enclosing method's return type —
|
||||||
|
// again a `return Extract(...)` call site, with the widgets/count
|
||||||
|
// parameters as plain in-params.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Heaviest_full_body_selection_becomes_return_call()
|
||||||
|
{
|
||||||
|
var (method, calls, tree) = Generate(87, 93);
|
||||||
|
|
||||||
|
Assert.Equal("Widget", method.ReturnType.ToString());
|
||||||
|
Assert.Equal(new[] { "int count", "List<Widget> widgets" },
|
||||||
|
method.ParameterList.Parameters.Select(p => p.ToString()));
|
||||||
|
|
||||||
|
var body = (BlockSyntax)method.Body!;
|
||||||
|
Assert.Equal(3, body.Statements.Count);
|
||||||
|
Assert.Equal("return best;", body.Statements[^1].ToString());
|
||||||
|
|
||||||
|
Assert.Single(calls);
|
||||||
|
Assert.Equal("return Extract(count, widgets);", calls[0].ToString());
|
||||||
|
|
||||||
|
VerifyZeroDiagnostics(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// ScoreReads (56..58): the enclosing method returns int but the selection
|
||||||
|
// ends with the COMPOSITE `return score + bonus;` — not nameable in v1,
|
||||||
|
// so the promoted signature is void and codegen must REFUSE (the report
|
||||||
|
// note already says: extract a local first, then re-run).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void ScoreReads_refuses_when_the_composite_trailing_return_is_not_nameable()
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
|
||||||
|
var resolved = SelectionResolver.Resolve(tree, 56, 58);
|
||||||
|
Assert.True(resolved.Succeeded, resolved.Error);
|
||||||
|
|
||||||
|
var suggestion = DataFlowClassifier.Classify(model, resolved);
|
||||||
|
var signature = SignatureBuilder.Build(model, resolved, suggestion);
|
||||||
|
Assert.Equal("void", signature.ReturnType);
|
||||||
|
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(
|
||||||
|
() => RefactoringGenerator.Generate(model, resolved, signature));
|
||||||
|
Assert.Contains("extract it into a local first", ex.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Accumulate for-loop (127..130): `acc` is declared BEFORE the selection,
|
||||||
|
// written inside and read after — the promoted signature keeps it as a
|
||||||
|
// ref parameter (the write-back flows the value out; void return, no
|
||||||
|
// return slot) and the call site is a plain call with `ref acc`.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Accumulate_forloop_selection_calls_with_ref_write_back()
|
||||||
|
{
|
||||||
|
var (method, calls, tree) = Generate(127, 130);
|
||||||
|
|
||||||
|
Assert.Equal("void", method.ReturnType.ToString());
|
||||||
|
Assert.Equal(new[] { "ref int acc", "int n" },
|
||||||
|
method.ParameterList.Parameters.Select(p => p.ToString()));
|
||||||
|
|
||||||
|
var body = (BlockSyntax)method.Body!;
|
||||||
|
Assert.Single(body.Statements); // the for-loop; no return appended (void)
|
||||||
|
|
||||||
|
Assert.Single(calls);
|
||||||
|
Assert.Equal("Extract(ref acc, n);", calls[0].ToString());
|
||||||
|
|
||||||
|
VerifyZeroDiagnostics(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Pairwise (141..147): two declared-inside locals both written inside and
|
||||||
|
// read after — the promoted signature returns the tuple `(int, int)` and
|
||||||
|
// the call site deconstructs it; the new body gains `return (x, y);`.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Pairwise_selection_generates_tuple_return_and_deconstruction_call()
|
||||||
|
{
|
||||||
|
var (method, calls, tree) = Generate(141, 147);
|
||||||
|
|
||||||
|
Assert.Equal("(int, int)", method.ReturnType.ToString());
|
||||||
|
Assert.Equal(new[] { "int n" }, method.ParameterList.Parameters.Select(p => p.ToString()));
|
||||||
|
|
||||||
|
var body = (BlockSyntax)method.Body!;
|
||||||
|
Assert.Equal(4, body.Statements.Count); // x, y, for-loop, appended tuple return
|
||||||
|
Assert.Equal("return (x, y);", body.Statements[^1].ToString());
|
||||||
|
|
||||||
|
Assert.Single(calls);
|
||||||
|
Assert.Equal("var (x, y) = Extract(n);", calls[0].ToString());
|
||||||
|
|
||||||
|
VerifyZeroDiagnostics(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// The unified diff, pinned exactly for a minimal edit (hunk header,
|
||||||
|
// context, deletion before insertion).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void UnifiedDiff_pins_the_hunk_format()
|
||||||
|
{
|
||||||
|
var diff = UnifiedDiff.Diff(
|
||||||
|
"a\nb\nc\n", "a\nX\nc\nd\n", "Demo.cs", "Demo.cs (generated)");
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"--- Demo.cs\n" +
|
||||||
|
"+++ Demo.cs (generated)\n" +
|
||||||
|
"@@ -1,3 +1,4 @@\n" +
|
||||||
|
" a\n" +
|
||||||
|
"-b\n" +
|
||||||
|
"+X\n" +
|
||||||
|
" c\n" +
|
||||||
|
"+d\n",
|
||||||
|
diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Identical texts produce a header with no hunks.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void UnifiedDiff_of_identical_texts_is_header_only()
|
||||||
|
{
|
||||||
|
Assert.Equal("--- a\n+++ b\n", UnifiedDiff.Diff("same\n", "same\n", "a", "b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static (MethodDeclarationSyntax Method, List<StatementSyntax> Calls, SyntaxTree Tree)
|
||||||
|
Generate(int startLine, int endLine)
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
|
||||||
|
var resolved = SelectionResolver.Resolve(tree, startLine, endLine);
|
||||||
|
Assert.True(resolved.Succeeded, resolved.Error);
|
||||||
|
|
||||||
|
var suggestion = DataFlowClassifier.Classify(model, resolved);
|
||||||
|
var signature = SignatureBuilder.Build(model, resolved, suggestion);
|
||||||
|
|
||||||
|
var refactoring = RefactoringGenerator.Generate(model, resolved, signature);
|
||||||
|
return (refactoring.NewMethod, refactoring.CallStatements.ToList(), refactoring.TransformedTree);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The yak's round-trip check: the transformed tree must compile with
|
||||||
|
/// ZERO diagnostics under the same scratch compilation the input file
|
||||||
|
/// compiles under.
|
||||||
|
/// </summary>
|
||||||
|
private static void VerifyZeroDiagnostics(SyntaxTree tree)
|
||||||
|
{
|
||||||
|
var compilation = CompilationLoader.CreateCompilation(tree, "DemoFixture");
|
||||||
|
var diagnostics = compilation.GetDiagnostics().ToList();
|
||||||
|
Assert.True(diagnostics.Count == 0, string.Join("\n", diagnostics));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for yak em 03: the signature promotion (SignatureBuilder) and the
|
||||||
|
/// formatted report (ReportFormatter) over the checked-in Demo.cs fixture.
|
||||||
|
/// Line anchors are the exact fixture lines pinned by em 01/02: ScoreReads
|
||||||
|
/// body 56..58, Summarize 68..76 (partial selection 68..72), Heaviest 87..93.
|
||||||
|
/// </summary>
|
||||||
|
public class ReportTests
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Signature promotion, the case em 02's comment promises: in Summarize
|
||||||
|
// lines 68..72, `total` sits in params[ref] AND returns (declared inside,
|
||||||
|
// reassigned inside, read after), and `i` sits in params AND locals (the
|
||||||
|
// for-header declares it inside). Promotion: both drop out of the params,
|
||||||
|
// total becomes the plain return value -> `int Extract(int limit)`.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Signature_for_Summarize_selection_promotes_declared_inside_variables()
|
||||||
|
{
|
||||||
|
var (report, formatted) = Compose(68, 72);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "limit" }, report.Signature.Params.Select(p => p.Name));
|
||||||
|
Assert.Equal("int", report.Signature.ReturnType);
|
||||||
|
Assert.Contains("suggested signature: int Extract(int limit)", formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Heaviest full body: `best` (declared inside, ref in the raw params,
|
||||||
|
// trailing return candidate) promotes to the return; the for-var `i`
|
||||||
|
// drops from the params; widgets/count stay as plain in-params.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Signature_for_Heaviest_returns_the_trailing_candidate_and_drops_declared_inside_params()
|
||||||
|
{
|
||||||
|
var (_, formatted) = Compose(87, 93);
|
||||||
|
|
||||||
|
// Parameter order follows the classifier's ordinal-by-name order —
|
||||||
|
// the same order the params bucket above prints (em 02 determinism).
|
||||||
|
Assert.Contains("suggested signature: Widget Extract(int count, List<Widget> widgets)", formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// No nameable return -> void (ScoreReads ends with `return score + bonus;`,
|
||||||
|
// a composite expression v1 cannot name — the report notes this); locals
|
||||||
|
// declared inside (seed, score) drop from the params; only the enclosing
|
||||||
|
// method's own parameter survives.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Signature_for_ScoreReads_is_void_and_carries_only_the_enclosing_parameter()
|
||||||
|
{
|
||||||
|
var (report, formatted) = Compose(56, 58);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "bonus" }, report.Signature.Params.Select(p => p.Name));
|
||||||
|
Assert.Equal("void", report.Signature.ReturnType);
|
||||||
|
Assert.Contains("suggested signature: void Extract(int bonus)", formatted);
|
||||||
|
|
||||||
|
// The void-ness has an explanation in the report: the composite
|
||||||
|
// trailing return is not nameable in v1.
|
||||||
|
Assert.Contains(report.Notes, n => n.Contains("composite return expression (score + bonus)", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// The report the CLI prints, pinned EXACTLY for one fixture selection
|
||||||
|
// (acceptance: "CLI report on the fixture matches the tests"). Heaviest
|
||||||
|
// full body is the richest case: raw buckets (params include the
|
||||||
|
// declared-inside best/i — the promotion happens only in the signature),
|
||||||
|
// extract-first with the invocation flag, no notes, signature last.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Formatted_report_for_Heaviest_matches_the_CLI_output_exactly()
|
||||||
|
{
|
||||||
|
var (_, formatted) = Compose(87, 93);
|
||||||
|
|
||||||
|
var expected =
|
||||||
|
"3 statement(s) selected, lines 87..93 in Heaviest(): LocalDeclarationStatement, ForStatement, ReturnStatement\n" +
|
||||||
|
"params: best (Widget) [ref]\n" +
|
||||||
|
"params: count (int) [in]\n" +
|
||||||
|
"params: i (int) [ref]\n" +
|
||||||
|
"params: widgets (List<Widget>) [in]\n" +
|
||||||
|
"returns: best (Widget)\n" +
|
||||||
|
"locals: i\n" +
|
||||||
|
"extract-first:\n" +
|
||||||
|
" - widgets[0] — List<Widget>.this[int] ×1\n" +
|
||||||
|
" - best.Bigger(widgets[i]) — Widget.Bigger(Widget) ×1 [hoisting changes eval count]\n" +
|
||||||
|
" - widgets[i] — List<Widget>.this[int] ×1\n" +
|
||||||
|
"suggested signature: Widget Extract(int count, List<Widget> widgets)\n";
|
||||||
|
|
||||||
|
Assert.Equal(expected, formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ExtractionReport Report, string Formatted) Compose(int startLine, int endLine)
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
|
||||||
|
var resolved = SelectionResolver.Resolve(tree, startLine, endLine);
|
||||||
|
Assert.True(resolved.Succeeded, resolved.Error);
|
||||||
|
|
||||||
|
var report = ExtractionReporter.Compose(model, resolved);
|
||||||
|
return (report, ReportFormatter.Format(report));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
using Domain;
|
using After;
|
||||||
|
|
||||||
namespace TestData;
|
namespace BeforeAfter.Tests.TestData;
|
||||||
|
|
||||||
/// <summary>Test data builder for a <see cref="Client"/> (TestDataBuilder pattern); <see cref="ValidClient"/> starts fresh — exactly what <c>new Client()</c> gives.</summary>
|
/// <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
|
public sealed class ClientBuilder
|
||||||
{
|
{
|
||||||
private readonly Client _client = new();
|
private readonly Client _client = new();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Using Include="Xunit" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Domain is referenced explicitly because these tests use domain
|
|
||||||
types directly (it also flows in transitively through Db). -->
|
|
||||||
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
|
||||||
<ProjectReference Include="..\..\src\Db\Db.csproj" />
|
|
||||||
<ProjectReference Include="..\TestData\TestData.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
using System.Reflection;
|
|
||||||
|
|
||||||
namespace Db.Tests;
|
|
||||||
|
|
||||||
/// <summary>The domain stayed clean: Client/Order do NOT derive from <see cref="DbBase"/> and expose no <see cref="DbContext"/>-typed member; asserted from the persistence side, where both are visible.</summary>
|
|
||||||
public class DomainStaysPlainTests
|
|
||||||
{
|
|
||||||
private const BindingFlags AllMembers =
|
|
||||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
|
||||||
|
|
||||||
// The leak is gone: no DbBase in the base chain, no DbContext-typed member (reflection).
|
|
||||||
[Theory]
|
|
||||||
[InlineData(typeof(Client))]
|
|
||||||
[InlineData(typeof(Order))]
|
|
||||||
public void Domain_objects_are_plain__no_DbBase_no_DbContext_member(Type type)
|
|
||||||
{
|
|
||||||
// Not DbBase, and not a derived type either (walk the base chain):
|
|
||||||
for (var t = type; t is not null && t != typeof(object); t = t.BaseType)
|
|
||||||
Assert.NotSame(typeof(DbBase), t);
|
|
||||||
|
|
||||||
// No DbContext-typed member, public or private:
|
|
||||||
foreach (var p in type.GetProperties(AllMembers))
|
|
||||||
Assert.True(p.PropertyType != typeof(DbContext),
|
|
||||||
$"{type.Name}.{p.Name} leaks a DbContext-typed property");
|
|
||||||
foreach (var f in type.GetFields(AllMembers))
|
|
||||||
Assert.True(f.FieldType != typeof(DbContext),
|
|
||||||
$"{type.Name}.{f.Name} leaks a DbContext-typed field");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Using Include="Xunit" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
|
||||||
<ProjectReference Include="..\TestData\TestData.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
namespace Domain.Tests;
|
|
||||||
|
|
||||||
/// <summary>Unit tests for the <see cref="OrderMoney"/> value object.</summary>
|
|
||||||
public class OrderMoneyTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void From_keeps_the_cents()
|
|
||||||
{
|
|
||||||
Assert.Equal(150_00, OrderMoney.From(150_00).Cents);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Zero_is_valid()
|
|
||||||
{
|
|
||||||
Assert.Equal(0, OrderMoney.From(0).Cents);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Negative_cents_are_rejected()
|
|
||||||
{
|
|
||||||
Assert.Throws<ArgumentOutOfRangeException>(() => OrderMoney.From(-1));
|
|
||||||
Assert.Throws<ArgumentOutOfRangeException>(() => _ = (OrderMoney)(-1)); // conversion validates too
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Money_compares_by_value_not_reference()
|
|
||||||
{
|
|
||||||
Assert.Equal(OrderMoney.From(150_00), OrderMoney.From(150_00));
|
|
||||||
Assert.NotEqual(OrderMoney.From(150_00), OrderMoney.From(151_00));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Converting_back_to_cents_is_explicit()
|
|
||||||
{
|
|
||||||
var money = OrderMoney.From(150_00);
|
|
||||||
|
|
||||||
Assert.Equal(150_00, (int)money);
|
|
||||||
Assert.Equal(150_00, money.Cents);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToString_shows_the_cents()
|
|
||||||
{
|
|
||||||
Assert.Equal("15000", OrderMoney.From(150_00).ToString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace TestData;
|
|
||||||
|
|
||||||
/// <summary>Test data builder for an <see cref="Order"/> (TestDataBuilder pattern); the only place tests touch an order's construction, so tests survive domain evolution.</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), wrapped in <see cref="OrderMoney"/>.</summary>
|
|
||||||
public OrderBuilder WithTotal(int totalInCents)
|
|
||||||
{
|
|
||||||
_order.TotalInCents = OrderMoney.From(totalInCents);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Order Build() => _order;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Using Include="Xunit" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
using static TestData.ClientBuilder;
|
|
||||||
using static TestData.OrderBuilder;
|
|
||||||
|
|
||||||
namespace Web.Tests;
|
|
||||||
|
|
||||||
/// <summary>The hand-written <see cref="DtoMappers"/> map both ways, never mutate their input, and round-trip losslessly; domain objects come from the test-data builders, DTO initializers stay literal.</summary>
|
|
||||||
public class DtoMapperTests
|
|
||||||
{
|
|
||||||
// (1) Mappers map all scalar fields, in both directions.
|
|
||||||
[Fact]
|
|
||||||
public void ToClientDto_maps_all_scalar_fields()
|
|
||||||
{
|
|
||||||
var id = Guid.NewGuid();
|
|
||||||
var client = ValidClient().WithId(id).WithName("Acme").Build();
|
|
||||||
|
|
||||||
var dto = DtoMappers.ToClientDto(client);
|
|
||||||
|
|
||||||
Assert.Equal(id, dto.Id);
|
|
||||||
Assert.Equal("Acme", dto.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToOrderDto_maps_all_scalar_fields()
|
|
||||||
{
|
|
||||||
var id = Guid.NewGuid();
|
|
||||||
var order = ValidOrder().WithId(id).WithDescription("one").WithTotal(150_00).Build();
|
|
||||||
|
|
||||||
var dto = DtoMappers.ToOrderDto(order);
|
|
||||||
|
|
||||||
Assert.Equal(id, dto.Id);
|
|
||||||
Assert.Equal("one", dto.Description);
|
|
||||||
Assert.Equal(150_00, dto.TotalInCents);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FromClientDto_maps_all_scalar_fields()
|
|
||||||
{
|
|
||||||
var id = Guid.NewGuid();
|
|
||||||
var dto = new ClientDto { Id = id, Name = "Acme" };
|
|
||||||
|
|
||||||
var client = DtoMappers.FromClientDto(dto);
|
|
||||||
|
|
||||||
Assert.Equal(id, client.Id);
|
|
||||||
Assert.Equal("Acme", client.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FromOrderDto_maps_all_scalar_fields()
|
|
||||||
{
|
|
||||||
var id = Guid.NewGuid();
|
|
||||||
var dto = new OrderDto { Id = id, Description = "one", TotalInCents = 150_00 };
|
|
||||||
|
|
||||||
var order = DtoMappers.FromOrderDto(dto);
|
|
||||||
|
|
||||||
Assert.Equal(id, order.Id);
|
|
||||||
Assert.Equal("one", order.Description);
|
|
||||||
Assert.Equal(150_00, order.TotalInCents.Cents);
|
|
||||||
}
|
|
||||||
|
|
||||||
// (2) Mappers map the relations, and the back-references stay consistent.
|
|
||||||
[Fact]
|
|
||||||
public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent()
|
|
||||||
{
|
|
||||||
var order = ValidOrder().WithDescription("one").Build();
|
|
||||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
|
||||||
|
|
||||||
var dto = DtoMappers.ToClientDto(client);
|
|
||||||
var orderDto = Assert.Single(dto.Orders);
|
|
||||||
|
|
||||||
// The order is mapped with the client's data:
|
|
||||||
Assert.Equal(order.Id, orderDto.Id);
|
|
||||||
Assert.Equal(order.Description, orderDto.Description);
|
|
||||||
|
|
||||||
// The back-reference cycle is resolved to the SAME dto reference:
|
|
||||||
Assert.Same(dto, orderDto.ClientDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FromClientDto_wires_the_order_client_back_reference()
|
|
||||||
{
|
|
||||||
var orderDto = new OrderDto { Description = "one" };
|
|
||||||
var dto = new ClientDto { Name = "Acme", Orders = new[] { orderDto } };
|
|
||||||
|
|
||||||
var client = DtoMappers.FromClientDto(dto);
|
|
||||||
var order = Assert.Single(client.Orders);
|
|
||||||
|
|
||||||
Assert.Equal(orderDto.Description, order.Description);
|
|
||||||
// Navigation fix-up: the reconstructed order points at the client:
|
|
||||||
Assert.Same(client, order.Client);
|
|
||||||
}
|
|
||||||
|
|
||||||
// (3) Round-trips preserve all state (compared by values, not reference).
|
|
||||||
[Fact]
|
|
||||||
public void Domain_round_trip_preserves_all_state()
|
|
||||||
{
|
|
||||||
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 = DtoMappers.FromClientDto(DtoMappers.ToClientDto(client));
|
|
||||||
|
|
||||||
Assert.Equal(client.Id, roundTrip.Id);
|
|
||||||
Assert.Equal(client.Name, roundTrip.Name);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Dto_round_trip_preserves_all_state()
|
|
||||||
{
|
|
||||||
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 = DtoMappers.ToClientDto(client);
|
|
||||||
var dtoOrder = dto.Orders[0];
|
|
||||||
|
|
||||||
var roundTrip = DtoMappers.ToClientDto(DtoMappers.FromClientDto(dto));
|
|
||||||
|
|
||||||
Assert.Equal(dto.Id, roundTrip.Id);
|
|
||||||
Assert.Equal(dto.Name, roundTrip.Name);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// (4) Mappers do not mutate their inputs (snapshot fields, map, compare).
|
|
||||||
[Fact]
|
|
||||||
public void ToClientDto_does_not_mutate_the_domain()
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
|
|
||||||
DtoMappers.ToClientDto(client);
|
|
||||||
DtoMappers.ToOrderDto(order);
|
|
||||||
|
|
||||||
Assert.Equal("Acme", client.Name);
|
|
||||||
Assert.Equal(ordersCount, client.Orders.Count);
|
|
||||||
Assert.Same(client, order.Client); // back-reference untouched
|
|
||||||
Assert.Equal("one", order.Description);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FromClientDto_does_not_mutate_the_dto()
|
|
||||||
{
|
|
||||||
var orderDto = new OrderDto { Description = "one" };
|
|
||||||
var dto = new ClientDto { Id = Guid.NewGuid(), Name = "Acme", Orders = new[] { orderDto } };
|
|
||||||
|
|
||||||
DtoMappers.FromClientDto(dto);
|
|
||||||
DtoMappers.FromOrderDto(orderDto);
|
|
||||||
|
|
||||||
Assert.Equal("Acme", dto.Name);
|
|
||||||
Assert.Same(orderDto, Assert.Single(dto.Orders)); // collection untouched
|
|
||||||
Assert.Null(orderDto.ClientDto); // back-reference untouched
|
|
||||||
Assert.Equal("one", orderDto.Description);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Using Include="Xunit" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Domain is referenced explicitly because these tests use domain
|
|
||||||
types directly (it also flows in transitively through Web).
|
|
||||||
Deliberately NO reference to Db — mirroring the Web layer. -->
|
|
||||||
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
|
||||||
<ProjectReference Include="..\..\src\Web\Web.csproj" />
|
|
||||||
<ProjectReference Include="..\TestData\TestData.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
using System.Reflection;
|
|
||||||
using static TestData.ClientBuilder;
|
|
||||||
using static TestData.OrderBuilder;
|
|
||||||
|
|
||||||
namespace Web.Tests;
|
|
||||||
|
|
||||||
/// <summary>The WebApp consumes only DTOs; persistence types are excluded at compile time (no Web → Db project reference).</summary>
|
|
||||||
public class WebAppTests
|
|
||||||
{
|
|
||||||
// The WebApp's public API references no domain type (reflection).
|
|
||||||
private static readonly Type[] BannedTypes = [typeof(Client), typeof(Order)];
|
|
||||||
|
|
||||||
private static IEnumerable<Type> TypeAndArguments(Type t)
|
|
||||||
{
|
|
||||||
yield return t;
|
|
||||||
if (t.IsGenericType)
|
|
||||||
foreach (var arg in t.GetGenericArguments())
|
|
||||||
foreach (var nested in TypeAndArguments(arg))
|
|
||||||
yield return nested;
|
|
||||||
if (t.IsArray && t.GetElementType() is { } elem)
|
|
||||||
foreach (var nested in TypeAndArguments(elem))
|
|
||||||
yield return nested;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void WebApp_public_api_references_no_domain_types()
|
|
||||||
{
|
|
||||||
var app = typeof(WebApp);
|
|
||||||
var banned = BannedTypes.ToList();
|
|
||||||
|
|
||||||
foreach (var m in app.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
|
||||||
{
|
|
||||||
var signature = m.GetParameters()
|
|
||||||
.Select(p => p.ParameterType)
|
|
||||||
.Append(m.ReturnType)
|
|
||||||
.ToList();
|
|
||||||
foreach (var t in signature.SelectMany(TypeAndArguments))
|
|
||||||
Assert.False(banned.Contains(t),
|
|
||||||
$"WebApp.{m.Name}({string.Join(", ", signature)}) references banned type {t}");
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var p in app.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
|
||||||
foreach (var t in TypeAndArguments(p.PropertyType))
|
|
||||||
Assert.False(banned.Contains(t),
|
|
||||||
$"WebApp.{p.Name} references banned type {t}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void WebApp_consumes_dtos()
|
|
||||||
{
|
|
||||||
var app = new WebApp();
|
|
||||||
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(DtoMappers.ToClientDto(client));
|
|
||||||
|
|
||||||
Assert.Contains("Acme", view);
|
|
||||||
Assert.Contains("order one", view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>ExtractMethod</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!--
|
||||||
|
Roslyn IS the tool: we parse C# source, build a scratch compilation and
|
||||||
|
ask the semantic model for data flow. Pinned to the latest stable on
|
||||||
|
nuget.org (5.9.0, Roslyn's versioning now tracks the VS release train).
|
||||||
|
-->
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
|
// CLIs are boring on purpose: argument parsing and printing live here, all
|
||||||
|
// Roslyn logic lives in Tooling/ so the tests can drive it directly.
|
||||||
|
|
||||||
|
string usage = "usage: ExtractMethod <file.cs> <startLine> <endLine> (1-based, inclusive)";
|
||||||
|
|
||||||
|
if (args.Length != 3)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(usage);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
string file = Path.GetFullPath(args[0]);
|
||||||
|
if (!File.Exists(file))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"error: file not found: {file}");
|
||||||
|
Console.Error.WriteLine(usage);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(args[1], out int startLine) || !int.TryParse(args[2], out int endLine))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"error: line numbers must be integers");
|
||||||
|
Console.Error.WriteLine(usage);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. parse the file
|
||||||
|
var tree = CompilationLoader.ParseFile(file);
|
||||||
|
|
||||||
|
// 2. build the scratch compilation (refs from TRUSTED_PLATFORM_ASSEMBLIES)
|
||||||
|
var compilation = CompilationLoader.CreateCompilation(tree, Path.GetFileNameWithoutExtension(file));
|
||||||
|
if (compilation.GetDiagnostics().Any(d => d.Severity == DiagnosticSeverity.Error))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("warning: the file does not compile cleanly under a plain Roslyn compilation; reporting syntax-level resolution only");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. snap the range to whole statements, report cleanly otherwise
|
||||||
|
var resolved = SelectionResolver.Resolve(tree, startLine, endLine);
|
||||||
|
if (!resolved.Succeeded)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"error: {resolved.Error}");
|
||||||
|
return SelectionResolver.ExitError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. compose the full suggestion (em 02 buckets + em 03 extract-first and
|
||||||
|
// signature), generate the refactoring (em 04), verify it and print the
|
||||||
|
// diff. Any failure is a clean error: message on stderr, same exit code
|
||||||
|
// as the resolver, never a stack trace.
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var extraction = ExtractionReporter.Compose(model, resolved);
|
||||||
|
Console.Write(ReportFormatter.Format(extraction));
|
||||||
|
|
||||||
|
// 5. generate the refactoring from the suggestion (em 04 codegen): new
|
||||||
|
// method + rewritten call site + transformed tree.
|
||||||
|
var refactoring = RefactoringGenerator.Generate(model, resolved, extraction.Signature);
|
||||||
|
|
||||||
|
// 6. round-trip check: the generated code must compile with zero
|
||||||
|
// diagnostics (same scratch-compilation path as the input file).
|
||||||
|
var transformedCompilation = CompilationLoader.CreateCompilation(
|
||||||
|
refactoring.TransformedTree, Path.GetFileNameWithoutExtension(file));
|
||||||
|
var diagnostics = transformedCompilation.GetDiagnostics().ToList();
|
||||||
|
if (diagnostics.Count > 0)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"error: round-trip check failed — the generated code has {diagnostics.Count} diagnostic(s):");
|
||||||
|
foreach (var diagnostic in diagnostics)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($" {diagnostic}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine("---- generated code ----");
|
||||||
|
Console.Error.Write(refactoring.TransformedText);
|
||||||
|
return SelectionResolver.ExitError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. v1 decision (em 04): print a unified diff to stdout — PREVIEW, never
|
||||||
|
// an in-place file rewrite. Applying the diff (or a future --write
|
||||||
|
// flag with backup semantics) is a deliberate next step, because
|
||||||
|
// mutating the caller's file is a different safety policy than
|
||||||
|
// generating code.
|
||||||
|
Console.Write(UnifiedDiff.Diff(
|
||||||
|
tree.ToString(),
|
||||||
|
refactoring.TransformedText,
|
||||||
|
Path.GetFileName(file),
|
||||||
|
Path.GetFileName(file) + " (generated)"));
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is InvalidOperationException or ArgumentException)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"error: {e.Message}");
|
||||||
|
return SelectionResolver.ExitError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns a plain .cs file into a Roslyn <see cref="CSharpCompilation"/> that a
|
||||||
|
/// semantic model can be queried against.
|
||||||
|
///
|
||||||
|
/// Shared by the CLI (Program.cs) and the tests so both exercise the same
|
||||||
|
/// loading path — the tests should not re-implement this, or they would be
|
||||||
|
/// testing their own assumptions instead of the tool.
|
||||||
|
/// </summary>
|
||||||
|
public static class CompilationLoader
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a file into a <see cref="SyntaxTree"/> using the latest language
|
||||||
|
/// version. Nothing here is project-specific: no .sln, no .csproj, no
|
||||||
|
/// generated files — the tool is a micro-tool that runs on a single file.
|
||||||
|
/// </summary>
|
||||||
|
public static SyntaxTree ParseFile(string path)
|
||||||
|
{
|
||||||
|
var text = File.ReadAllText(Path.GetFullPath(path));
|
||||||
|
return CSharpSyntaxTree.ParseText(
|
||||||
|
text,
|
||||||
|
path: path,
|
||||||
|
options: CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a scratch compilation over <paramref name="tree"/>.
|
||||||
|
///
|
||||||
|
/// WHY <c>TRUSTED_PLATFORM_ASSEMBLIES</c>: it is the runtime-resolved list
|
||||||
|
/// of the actual BCL assemblies this process (net10.0) runs on, e.g.
|
||||||
|
/// System.Private.CoreLib, System.Runtime, System.Console. Referencing
|
||||||
|
/// them means an arbitrary fixture can bind List<T>, string, and the
|
||||||
|
/// rest of the BCL without any NuGet/EF/SQLite haul. (A single
|
||||||
|
/// typeof(object) reference is NOT enough on modern .NET — that only pulls
|
||||||
|
/// in the core library, and Console/GC/enums etc. fail to bind.)
|
||||||
|
/// </summary>
|
||||||
|
public static CSharpCompilation CreateCompilation(SyntaxTree tree, string assemblyName)
|
||||||
|
{
|
||||||
|
var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
|
||||||
|
.Split(Path.PathSeparator)
|
||||||
|
.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p));
|
||||||
|
|
||||||
|
return CSharpCompilation.Create(
|
||||||
|
assemblyName,
|
||||||
|
new[] { tree },
|
||||||
|
refs,
|
||||||
|
new CSharpCompilationOptions(
|
||||||
|
OutputKind.DynamicallyLinkedLibrary,
|
||||||
|
// Match the repo's net10.0 projects so the fixture's nullable
|
||||||
|
// annotations compile without warnings.
|
||||||
|
nullableContextOptions: NullableContextOptions.Enable));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Yak em 02: data-flow classification. Turns a resolved selection into the
|
||||||
|
// three buckets the extract-method report is built from (params / returns /
|
||||||
|
// locals), using Roslyn's DataFlowAnalysis. Names and types are plain strings
|
||||||
|
// for v1; the records are the stable contract em 03 (extract-first + report)
|
||||||
|
// and em 04 (codegen) build on.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>One suggested parameter of the extracted method (v1: plain strings).</summary>
|
||||||
|
/// <param name="Name">Variable name as it appears in the source.</param>
|
||||||
|
/// <param name="Type">Type as a display string (e.g. <c>int</c>, <c>string</c>).</param>
|
||||||
|
/// <param name="ByRef">True when the extraction must take the variable by <c>ref</c>
|
||||||
|
/// (write-back required); see <see cref="DataFlowClassifier.IsReassignedInside"/> for
|
||||||
|
/// the precise v1 rule.</param>
|
||||||
|
public sealed record ParamSuggestion(string Name, string Type, bool ByRef);
|
||||||
|
|
||||||
|
/// <summary>One return candidate of the extracted method (v1: plain strings).</summary>
|
||||||
|
/// <param name="Name">Variable name as it appears in the source.</param>
|
||||||
|
/// <param name="Type">Type as a display string — the signature line of em 03
|
||||||
|
/// prints it as the extracted method's return type.</param>
|
||||||
|
public sealed record ReturnSuggestion(string Name, string Type);
|
||||||
|
|
||||||
|
/// <summary>The classified buckets of one selection (v1: symbol names as plain strings).</summary>
|
||||||
|
/// <param name="Params">Variables the selection READS and the new method therefore
|
||||||
|
/// receives: read locals + the enclosing method's parameters (parent decision #6).
|
||||||
|
/// A variable may also appear in <see cref="Locals"/> and/or <see cref="Returns"/> —
|
||||||
|
/// v1 reports per-bucket; em 03's signature builder dedupes into a coherent
|
||||||
|
/// signature (a variable DECLARED inside the selection cannot be a parameter).</param>
|
||||||
|
/// <param name="Returns">Variables written inside AND read after the selection
|
||||||
|
/// (tail data flow), plus the value of a trailing <c>return X;</c> when X is a
|
||||||
|
/// simple name — they must flow out of the extraction. Typed since em 03: the
|
||||||
|
/// signature line needs the return type, not just the name.</param>
|
||||||
|
/// <param name="Locals">Variables written inside and never read after: scratch
|
||||||
|
/// locals of the new method.</param>
|
||||||
|
public sealed record ExtractionSuggestion(
|
||||||
|
IReadOnlyList<ParamSuggestion> Params,
|
||||||
|
IReadOnlyList<ReturnSuggestion> Returns,
|
||||||
|
IReadOnlyList<string> Locals);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Buckets a <see cref="SelectionReport"/> via Roslyn data-flow analysis.
|
||||||
|
///
|
||||||
|
/// Two regions are analyzed, in the SAME tree and compilation (so symbols keep
|
||||||
|
/// their identity and <see cref="SymbolEqualityComparer"/> matches them across
|
||||||
|
/// regions):
|
||||||
|
/// 1. the selection itself (parent spec: ReadInside / WrittenInside /
|
||||||
|
/// WrittenOutside),
|
||||||
|
/// 2. the TAIL: the statements AFTER the selection in the same enclosing
|
||||||
|
/// block — its ReadInside is the "read after" set (parent decision #4;
|
||||||
|
/// branch-insensitive over-approximation, see <see cref="Classify"/>).
|
||||||
|
///
|
||||||
|
/// WHY the two-argument overload and not a synthetic BlockSyntax (parent
|
||||||
|
/// decision #3 was "spike it"): the em 01 spike proved a synthetic block is
|
||||||
|
/// NOT part of the tree, so <c>AnalyzeDataFlow(block)</c> throws
|
||||||
|
/// <c>ArgumentException: statements not within tree</c>. Making it work would
|
||||||
|
/// mean re-parsing the method body, which RE-BINDS every symbol — the tail's
|
||||||
|
/// symbols would no longer equal the selection's, and the
|
||||||
|
/// written-inside ∩ read-after intersection (the return bucket) would break.
|
||||||
|
/// The two-argument overload analyzes a contiguous run in one statement list,
|
||||||
|
/// and both endpoints ARE tree nodes — no re-parse, identity preserved. It is
|
||||||
|
/// the LIVE path; the synthetic block is the DEAD path (pinned in tests).
|
||||||
|
/// </summary>
|
||||||
|
public static class DataFlowClassifier
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data flow of the selected statements themselves.
|
||||||
|
/// Single statement → the one-argument overload; many contiguous
|
||||||
|
/// statements → <see cref="SemanticModel.AnalyzeDataFlow(SyntaxNode, SyntaxNode)"/>.
|
||||||
|
/// The resolver guarantees the selection is a contiguous run of whole
|
||||||
|
/// statements in one statement list, which is exactly that overload's
|
||||||
|
/// contract (both endpoints in the tree, same parent list).
|
||||||
|
/// </summary>
|
||||||
|
public static DataFlowAnalysis AnalyzeSelectionFlow(SemanticModel model, SelectionReport selection)
|
||||||
|
=> AnalyzeRegion(model, selection.Statements);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data flow of the statements AFTER the selection in the same enclosing
|
||||||
|
/// block (parent decision #4's "tail flow"). Returns null when the
|
||||||
|
/// selection already reaches the end of the block (nothing to read after).
|
||||||
|
/// The tail is again a contiguous run in one statement list, so the same
|
||||||
|
/// two-argument overload applies — no synthetic block needed, and symbol
|
||||||
|
/// identity with the selection analysis is preserved (see class comment).
|
||||||
|
/// </summary>
|
||||||
|
public static DataFlowAnalysis? AnalyzeTailFlow(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
var body = selection.Method?.Body;
|
||||||
|
if (body is null)
|
||||||
|
{
|
||||||
|
return null; // resolution succeeded but had no body: nothing to do (defensive)
|
||||||
|
}
|
||||||
|
|
||||||
|
var all = body.Statements;
|
||||||
|
int lastIndex = all.IndexOf(selection.Statements[^1]); // reference identity: same tree
|
||||||
|
int tailCount = all.Count - lastIndex - 1;
|
||||||
|
if (tailCount == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnalyzeRegion(model, all.Skip(lastIndex + 1).Take(tailCount).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The three report buckets for a resolved selection.
|
||||||
|
///
|
||||||
|
/// RULES (parent spec):
|
||||||
|
/// - params: every variable READ inside that is a local or the enclosing
|
||||||
|
/// method's parameter (decision #6 keeps fields/properties/statics out —
|
||||||
|
/// they are the extract-first bucket of em 03). The implicit `this`
|
||||||
|
/// parameter is filtered out: it is the instance, not a passable value.
|
||||||
|
/// - returns: written inside AND read after the selection (tail flow),
|
||||||
|
/// plus the value of a trailing `return X;` when X is a simple name.
|
||||||
|
/// - locals: written inside and never read after → scratch locals.
|
||||||
|
///
|
||||||
|
/// OVER-APPROXIMATION (decision #4, kept for v1, comment is the contract):
|
||||||
|
/// "read after" is ReadInside of the whole tail, branch-insensitively. We
|
||||||
|
/// do NOT track whether the write from inside the selection actually
|
||||||
|
/// reaches each tail read (e.g. the variable could be overwritten in the
|
||||||
|
/// tail before its next read). Consequences: some variables are reported
|
||||||
|
/// as returns that a precise analysis would classify as locals. Accepted.
|
||||||
|
///
|
||||||
|
/// Overlap between buckets is a v1 artifact of the parent spec, not a bug:
|
||||||
|
/// a variable that is read, declared and reassigned inside (e.g. `total`)
|
||||||
|
/// legitimately lands in params (by-ref: a write-back is owed) AND in
|
||||||
|
/// locals/returns. em 03 promotes such variables into the signature.
|
||||||
|
/// </summary>
|
||||||
|
public static ExtractionSuggestion Classify(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
var selectionFlow = AnalyzeSelectionFlow(model, selection);
|
||||||
|
if (!selectionFlow.Succeeded)
|
||||||
|
{
|
||||||
|
// Bindable code that Roslyn cannot analyze means a tool bug or an
|
||||||
|
// unhandled file shape; be loud instead of silently producing an
|
||||||
|
// empty classification.
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"data-flow analysis of the selection failed to bind (Succeeded == false)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var readInside = selectionFlow.ReadInside;
|
||||||
|
var writtenInside = selectionFlow.WrittenInside;
|
||||||
|
// WrittenOutside is not needed for the v1 buckets (a variable written
|
||||||
|
// outside the selection and read inside is already a by-value in-param;
|
||||||
|
// one written inside and outside is caught by IsReassignedInside).
|
||||||
|
// It is still computed/exposed via AnalyzeSelectionFlow — the sets are
|
||||||
|
// the curriculum — but unused here by design.
|
||||||
|
|
||||||
|
// ReadInside is ImmutableArray (not a set): fine for Contains lookups.
|
||||||
|
var readAfter = AnalyzeTailFlow(model, selection)?.ReadInside ?? ImmutableArray<ISymbol>.Empty;
|
||||||
|
|
||||||
|
// ---- returns: written inside ∧ read after, plus trailing return X ----
|
||||||
|
// Typed since em 03 (the signature line needs the return type). The
|
||||||
|
// symbols come straight from the flow sets, so types are exact; the
|
||||||
|
// trailing-return candidate resolves its own identifier below.
|
||||||
|
var returnSymbols = writtenInside
|
||||||
|
.Where(v => readAfter.Contains(v))
|
||||||
|
.ToDictionary(v => v.Name, VariableTypeString, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var trailingReturn = TrailingReturnSuggestion(model, selection);
|
||||||
|
if (trailingReturn is { } candidate)
|
||||||
|
{
|
||||||
|
returnSymbols.TryAdd(candidate.Name, candidate.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
var returnSuggestions = returnSymbols
|
||||||
|
.Select(kv => new ReturnSuggestion(kv.Key, kv.Value))
|
||||||
|
.OrderBy(r => r.Name, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// ---- locals: written inside, never read after -> scratch locals ----
|
||||||
|
var localNames = writtenInside
|
||||||
|
.Where(v => !returnSymbols.ContainsKey(v.Name))
|
||||||
|
.Select(v => v.Name)
|
||||||
|
.OrderBy(n => n, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// ---- params: reads that are locals / enclosing-method parameters ----
|
||||||
|
var paramSuggestions = readInside
|
||||||
|
.Where(v => v is ILocalSymbol or IParameterSymbol)
|
||||||
|
.Where(v => v is not IParameterSymbol { IsThis: true }) // `this` is the instance, not a value
|
||||||
|
.OrderBy(v => v.Name, StringComparer.Ordinal)
|
||||||
|
.Select(v => new ParamSuggestion(
|
||||||
|
v.Name,
|
||||||
|
VariableTypeString(v),
|
||||||
|
IsReassignedInside(model, v, selection.Statements)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new ExtractionSuggestion(paramSuggestions, returnSuggestions, localNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display string of a variable's type. ISymbol has no Type member; only
|
||||||
|
/// locals and parameters carry one. Used for the params bucket AND (since
|
||||||
|
/// em 03) the returns bucket — the signature line prints it.
|
||||||
|
/// </summary>
|
||||||
|
private static string VariableTypeString(ISymbol variable) => variable switch
|
||||||
|
{
|
||||||
|
ILocalSymbol local => local.Type?.ToDisplayString(Displays.TypeFormat) ?? "unknown",
|
||||||
|
IParameterSymbol parameter => parameter.Type?.ToDisplayString(Displays.TypeFormat) ?? "unknown",
|
||||||
|
_ => "unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes a contiguous run of statements (one or many) in one statement
|
||||||
|
/// list. One statement → the one-argument overload; several → the two-
|
||||||
|
/// argument overload. Both endpoints are real tree nodes; a synthetic
|
||||||
|
/// block would throw "statements not within tree" (see class comment).
|
||||||
|
/// </summary>
|
||||||
|
private static DataFlowAnalysis AnalyzeRegion(SemanticModel model, IReadOnlyList<StatementSyntax> statements)
|
||||||
|
{
|
||||||
|
// Both overloads are nullable-annotated in Roslyn 5.x; null here means
|
||||||
|
// the region could not be bound, which the caller treats like
|
||||||
|
// Succeeded == false (loud failure, never a silent empty result).
|
||||||
|
DataFlowAnalysis? flow = statements.Count == 1
|
||||||
|
? model.AnalyzeDataFlow(statements[0])
|
||||||
|
: model.AnalyzeDataFlow(statements[0], statements[^1]);
|
||||||
|
return flow ?? throw new InvalidOperationException(
|
||||||
|
$"data-flow analysis returned null for a {statements.Count}-statement region (unable to bind)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ByRef flag (parent spec: "mark ref if also written inside").
|
||||||
|
///
|
||||||
|
/// REFINEMENT, with the WHY: a variable's own declaration write (its
|
||||||
|
/// initializer) must NOT force a ref — the extraction declares the
|
||||||
|
/// variable itself and the caller never owed a write-back. Only a
|
||||||
|
/// REASSIGNMENT inside the selection (assignment, compound assignment,
|
||||||
|
/// ++/--, ref/out argument) represents a value written back to a variable
|
||||||
|
/// the caller already owns, which is what demands by-ref. Without this
|
||||||
|
/// refinement, every local declared-and-read inside (ScoreReads' seed and
|
||||||
|
/// score, per the note-6 fixture map) would be wrongly flagged ref.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsReassignedInside(SemanticModel model, ISymbol variable, IReadOnlyList<StatementSyntax> statements)
|
||||||
|
{
|
||||||
|
foreach (var statement in statements)
|
||||||
|
{
|
||||||
|
foreach (var node in statement.DescendantNodesAndSelf())
|
||||||
|
{
|
||||||
|
switch (node)
|
||||||
|
{
|
||||||
|
// `x = ...`, `x += ...`, `x ??= ...`, `x ?? y` is read, etc.:
|
||||||
|
// the LHS of any AssignmentExpression is a write position.
|
||||||
|
case AssignmentExpressionSyntax a when RefersTo(a.Left, model, variable):
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// `x++`, `++x`, `x--`, `--x` (read-modify-write). The
|
||||||
|
// unary-expression Kind distinguishes inc/dec from other
|
||||||
|
// unary operators (e.g. `-x`, `!x` are reads only).
|
||||||
|
case PostfixUnaryExpressionSyntax p
|
||||||
|
when p.Kind() is SyntaxKind.PostIncrementExpression or SyntaxKind.PostDecrementExpression
|
||||||
|
&& RefersTo(p.Operand, model, variable):
|
||||||
|
case PrefixUnaryExpressionSyntax q
|
||||||
|
when q.Kind() is SyntaxKind.PreIncrementExpression or SyntaxKind.PreDecrementExpression
|
||||||
|
&& RefersTo(q.Operand, model, variable):
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// `Foo(ref x)`, `Foo(out x)`: ref/out arguments write the
|
||||||
|
// variable (out even more so).
|
||||||
|
case ArgumentSyntax arg
|
||||||
|
when !arg.RefOrOutKeyword.IsKind(SyntaxKind.None)
|
||||||
|
&& RefersTo(arg.Expression, model, variable):
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when <paramref name="expr"/> is a simple reference to <paramref name="variable"/>.</summary>
|
||||||
|
private static bool RefersTo(ExpressionSyntax expr, SemanticModel model, ISymbol variable)
|
||||||
|
=> expr is IdentifierNameSyntax
|
||||||
|
&& SymbolEqualityComparer.Default.Equals(model.GetSymbolInfo(expr).Symbol, variable);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parent spec: "if the selection ends with `return X;`, X is the candidate."
|
||||||
|
/// Returns the simple-name candidate with its type, or null when the
|
||||||
|
/// returned expression is not a simple name — an expression like
|
||||||
|
/// `score + bonus` is not nameable as a v1 string (composite return
|
||||||
|
/// expressions are extract-first shape for em 03: the report suggests
|
||||||
|
/// extracting it into a local first). Empty return ('return;') has no
|
||||||
|
/// candidate.
|
||||||
|
/// </summary>
|
||||||
|
private static ReturnSuggestion? TrailingReturnSuggestion(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
var last = selection.Statements[^1];
|
||||||
|
if (last is not ReturnStatementSyntax { Expression: IdentifierNameSyntax name })
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = model.GetSymbolInfo(name).Symbol switch
|
||||||
|
{
|
||||||
|
ILocalSymbol local => local.Type?.ToDisplayString(Displays.TypeFormat) ?? "unknown",
|
||||||
|
IParameterSymbol parameter => parameter.Type?.ToDisplayString(Displays.TypeFormat) ?? "unknown",
|
||||||
|
_ => "unknown",
|
||||||
|
};
|
||||||
|
return new ReturnSuggestion(name.Identifier.ValueText, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The trailing return statement when its expression is NOT a simple
|
||||||
|
/// name (and not absent) — the composite-return shape the em 03 report
|
||||||
|
/// flags as "extract a local first" (see <see cref="TrailingReturnSuggestion"/>).
|
||||||
|
/// </summary>
|
||||||
|
public static ReturnStatementSyntax? CompositeTrailingReturn(SelectionReport selection)
|
||||||
|
=> selection.Statements[^1] is ReturnStatementSyntax { Expression: not null and not IdentifierNameSyntax } ret
|
||||||
|
? ret
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compact symbol display for the suggestion report. The defaults are wrong
|
||||||
|
/// for a report a human reads next to their own source:
|
||||||
|
/// - <c>ToDisplayString()</c> fully qualifies every namespace
|
||||||
|
/// (<c>System.Collections.Generic.List<Widget></c>),
|
||||||
|
/// - <c>ToMinimalDisplayString()</c> keeps containing types AND prefixes
|
||||||
|
/// members with their own type (<c>int Demo._seed</c>).
|
||||||
|
/// The report wants the shortest form that stays readable in context:
|
||||||
|
/// types as bare names (<c>Widget</c>, <c>List<Widget></c>), members as
|
||||||
|
/// <c>ContainingType.Member</c> with parameter types.
|
||||||
|
/// </summary>
|
||||||
|
internal static class Displays
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Type display for suggestion lines: shortest unambiguous-enough form
|
||||||
|
/// (no namespaces, no containing types, keyword spellings for special
|
||||||
|
/// types, nullable annotations kept). Built explicitly because
|
||||||
|
/// MinimallyQualifiedFormat keeps containing types for nested types
|
||||||
|
/// (<c>Demo.Widget</c>) and has no With-style override here.
|
||||||
|
/// </summary>
|
||||||
|
internal static readonly SymbolDisplayFormat TypeFormat = new(
|
||||||
|
globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted,
|
||||||
|
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameOnly,
|
||||||
|
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters
|
||||||
|
| SymbolDisplayGenericsOptions.IncludeVariance,
|
||||||
|
miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes
|
||||||
|
| SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers
|
||||||
|
| SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
|
||||||
|
|
||||||
|
/// <summary>Compact member display for extract-first entries:
|
||||||
|
/// <c>Demo._seed</c>, <c>Demo.Scale</c>, <c>Widget.Bigger(Widget)</c>,
|
||||||
|
/// <c>List<Widget>.this[int]</c>.</summary>
|
||||||
|
internal static string Member(ISymbol symbol) => symbol switch
|
||||||
|
{
|
||||||
|
IMethodSymbol method =>
|
||||||
|
$"{Type(method.ContainingType)}.{method.Name}({Parameters(method.Parameters)})",
|
||||||
|
IPropertySymbol { IsIndexer: true } indexer =>
|
||||||
|
$"{Type(indexer.ContainingType)}.this[{Parameters(indexer.Parameters)}]",
|
||||||
|
IFieldSymbol or IPropertySymbol or IEventSymbol =>
|
||||||
|
$"{Type(symbol.ContainingType)}.{symbol.Name}",
|
||||||
|
_ => symbol.ToDisplayString(TypeFormat),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Parameters(ImmutableArray<IParameterSymbol> parameters)
|
||||||
|
=> string.Join(", ", parameters.Select(p => Type(p.Type)));
|
||||||
|
|
||||||
|
private static string Type(ITypeSymbol? type) => type?.ToDisplayString(TypeFormat) ?? "?";
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One deduplicated "extract-first" candidate (parent bucket spec): a read
|
||||||
|
/// expression that is NOT a simple local/parameter reference, so the classic
|
||||||
|
/// refactoring sequence is to Extract-Variable it FIRST and re-run the tool —
|
||||||
|
/// the extraction then receives a clean variable instead of a nested
|
||||||
|
/// expression. <see cref="Text"/> plus <see cref="SymbolDisplay"/> identify
|
||||||
|
/// the candidate; <see cref="Occurrences"/> is how often the same candidate
|
||||||
|
/// (same symbol + same source text) is read inside the selection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Text">Source text of the expression (trivia-free).</param>
|
||||||
|
/// <param name="SymbolDisplay">The bound symbol, minimally qualified for the
|
||||||
|
/// report, or <c><unbound></c> when the expression resolves to no
|
||||||
|
/// symbol (e.g. a cast — conversions are not symbols).</param>
|
||||||
|
/// <param name="Occurrences">How often this exact candidate is read.</param>
|
||||||
|
/// <param name="IsInvocation">True for method invocations: hoisting one into
|
||||||
|
/// a variable changes HOW OFTEN it is evaluated (pure calls are safe to
|
||||||
|
/// hoist, effectful ones are a behavior change — v1 cannot tell them apart,
|
||||||
|
/// so every invocation is flagged).</param>
|
||||||
|
/// <param name="OwnMemberKind"><c>field</c> or <c>property</c> when the
|
||||||
|
/// accessed member belongs to the class that owns the selection — hoisting
|
||||||
|
/// such an access is OPTIONAL because a same-class extracted method can still
|
||||||
|
/// see it (it is still listed: extraction often precedes moving the method to
|
||||||
|
/// another class, where the field would need to travel as a parameter).</param>
|
||||||
|
public sealed record ExtractFirstEntry(
|
||||||
|
string Text,
|
||||||
|
string SymbolDisplay,
|
||||||
|
int Occurrences,
|
||||||
|
bool IsInvocation,
|
||||||
|
string? OwnMemberKind)
|
||||||
|
{
|
||||||
|
/// <summary>Own-class field/property reads are optional to hoist (see <see cref="OwnMemberKind"/>).</summary>
|
||||||
|
public bool Optional => OwnMemberKind is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The extract-first scan (yak em 03, parent bucket spec): find the READ
|
||||||
|
/// expressions of the selection that are not simple local/parameter
|
||||||
|
/// references — member/property/field access, element access, invocations,
|
||||||
|
/// casts — dedupe them by semantic key (bound symbol + source text), count
|
||||||
|
/// occurrences, flag invocations, and mark own-class field/property access
|
||||||
|
/// as optional.
|
||||||
|
///
|
||||||
|
/// WHY a hand-rolled recursive walk instead of one DescendantNodes query:
|
||||||
|
/// "which expression positions are READS" is exactly what data-flow analysis
|
||||||
|
/// answers for VARIABLES (em 02's ReadInside), but for nested expressions
|
||||||
|
/// the read/write distinction must come from the SYNTAX shape — and the scan
|
||||||
|
/// needs three shape rules a flat query cannot express:
|
||||||
|
/// 1. assignment LEFT sides are writes, skipped entirely (decision #5),
|
||||||
|
/// 2. the direct callee of an invocation (and the container of an element
|
||||||
|
/// access) is covered by that invocation/access — reporting `best.Bigger`
|
||||||
|
/// AND `best.Bigger(...)` would double-count one call,
|
||||||
|
/// 3. a member access's NAME is part of the access node, not an
|
||||||
|
/// independent expression — visiting it as an IdentifierName would
|
||||||
|
/// report the same access twice (e.g. `a.B.C` as both the member
|
||||||
|
/// access and a bare `C`).
|
||||||
|
///
|
||||||
|
/// KNOWN v1 APPROXIMATIONS (documented, accepted):
|
||||||
|
/// - Reads-only: non-variable assignment left-hand sides (`arr[i] = 5`) are
|
||||||
|
/// skipped whole — including reads NESTED in them (`arr` in
|
||||||
|
/// `arr[i].X = 5`). Surfaced as a report note (see
|
||||||
|
/// <see cref="HasNonVariableAssignmentLeftSide"/>).
|
||||||
|
/// - Compound-assignment LHS also reads its old value (`obj.Count += 1`);
|
||||||
|
/// v1 skips it with the rest of the LHS.
|
||||||
|
/// - Chained accesses report each link (`a.B.C` lists `a.B` and `a.B.C`)
|
||||||
|
/// except the direct callee of a call, which the call covers.
|
||||||
|
/// - Null-conditional chains are approximated: `a?.B` reports the member
|
||||||
|
/// binding (`.B`) with its symbol; the receiver is not re-reported.
|
||||||
|
/// - The scan classifies expression NODES; a statement's descendant TYPE
|
||||||
|
/// references (`Widget best = ...`) are reached by the generic descent but
|
||||||
|
/// filtered out because they resolve to types, not values.
|
||||||
|
/// </summary>
|
||||||
|
public static class ExtractFirstScanner
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scans the selection and returns the deduplicated extract-first
|
||||||
|
/// candidates in first-occurrence (source) order.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<ExtractFirstEntry> Scan(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
// The resolver guarantees Method is non-null on a successful report;
|
||||||
|
// its containing type is what "own-class" is measured against.
|
||||||
|
var enclosingType = (model.GetDeclaredSymbol(selection.Method!) as IMethodSymbol)?.ContainingType;
|
||||||
|
|
||||||
|
var entries = new List<ExtractFirstEntry>();
|
||||||
|
var indexByKey = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Records one candidate occurrence. The semantic key is the bound
|
||||||
|
// symbol PLUS the source text: the same member read through different
|
||||||
|
// expressions (`widgets[0]` vs `widgets[i]`) stays separate, while an
|
||||||
|
// identical expression read twice (`_seed` twice in RepeatReads)
|
||||||
|
// dedupes into one entry with Occurrences > 1.
|
||||||
|
void Report(ExpressionSyntax expr, bool invocation)
|
||||||
|
{
|
||||||
|
var info = model.GetSymbolInfo(expr);
|
||||||
|
// Prefer the resolved symbol; on an ambiguous binding the first
|
||||||
|
// candidate still identifies the member better than nothing.
|
||||||
|
var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
|
||||||
|
|
||||||
|
var text = expr.ToString(); // trivia-free expression text
|
||||||
|
var key = $"{symbol?.ToDisplayString() ?? "<unbound>"}|{text}";
|
||||||
|
var display = symbol is null ? "<unbound>" : Displays.Member(symbol);
|
||||||
|
var ownKind = OwnMemberKind(symbol, enclosingType);
|
||||||
|
|
||||||
|
if (indexByKey.TryGetValue(key, out var index))
|
||||||
|
{
|
||||||
|
entries[index] = entries[index] with { Occurrences = entries[index].Occurrences + 1 };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
indexByKey[key] = entries.Count;
|
||||||
|
entries.Add(new ExtractFirstEntry(text, display, 1, invocation, ownKind));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Own-class data member → hoisting is optional (parent bucket spec).
|
||||||
|
// Exact containing-type match: base-class members would need a base
|
||||||
|
// reference in the extracted method — not v1's "optional" case.
|
||||||
|
static string? OwnMemberKind(ISymbol? symbol, INamedTypeSymbol? enclosingType)
|
||||||
|
{
|
||||||
|
if (enclosingType is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return symbol switch
|
||||||
|
{
|
||||||
|
IFieldSymbol field when SymbolEqualityComparer.Default.Equals(field.ContainingType, enclosingType)
|
||||||
|
=> "field",
|
||||||
|
IPropertySymbol property when SymbolEqualityComparer.Default.Equals(property.ContainingType, enclosingType)
|
||||||
|
=> "property",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classifies a bare simple name. Simple names ARE reported when they
|
||||||
|
// bind to anything that is not a variable or a type/namespace — that
|
||||||
|
// is how the field read `_seed` and the property read `Scale` (both
|
||||||
|
// written as bare identifiers) enter this bucket (decision #6 keeps
|
||||||
|
// them out of the params bucket).
|
||||||
|
void ClassifyIdentifier(IdentifierNameSyntax id)
|
||||||
|
{
|
||||||
|
if (id.IsVar)
|
||||||
|
{
|
||||||
|
return; // the contextual `var` keyword is not a value read
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbol = model.GetSymbolInfo(id).Symbol;
|
||||||
|
switch (symbol)
|
||||||
|
{
|
||||||
|
case null:
|
||||||
|
return; // unresolvable: nothing sensible to report
|
||||||
|
case ILocalSymbol or IParameterSymbol:
|
||||||
|
return; // the params bucket (decision #6), not extract-first
|
||||||
|
case INamespaceSymbol or ITypeSymbol:
|
||||||
|
return; // a type/namespace mention (e.g. `Widget`), not a value read
|
||||||
|
case IDiscardSymbol:
|
||||||
|
return; // discards are write sinks, never reads
|
||||||
|
default:
|
||||||
|
Report(id, invocation: false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>nameof is spelled like a call but reads no member state.</summary>
|
||||||
|
static bool IsNameOf(InvocationExpressionSyntax inv)
|
||||||
|
=> inv.Expression is IdentifierNameSyntax { Identifier.ValueText: "nameof" };
|
||||||
|
|
||||||
|
void Visit(SyntaxNode node, bool suppress)
|
||||||
|
{
|
||||||
|
switch (node)
|
||||||
|
{
|
||||||
|
// Decision #5: v1 is a READS-only scan. The whole LHS subtree
|
||||||
|
// is skipped — writes to non-variables are invisible (the
|
||||||
|
// report notes this when it applies; see
|
||||||
|
// HasNonVariableAssignmentLeftSide). Nested reads inside an
|
||||||
|
// LHS (the `arr` in `arr[i].X = 5`) are the documented loss.
|
||||||
|
case AssignmentExpressionSyntax assignment:
|
||||||
|
Visit(assignment.Right, suppress: false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case InvocationExpressionSyntax inv when IsNameOf(inv):
|
||||||
|
foreach (var arg in inv.ArgumentList.Arguments)
|
||||||
|
{
|
||||||
|
Visit(arg.Expression, suppress: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case InvocationExpressionSyntax inv:
|
||||||
|
// The invocation is the candidate; its direct callee is
|
||||||
|
// covered by it (suppress = true below).
|
||||||
|
if (!suppress)
|
||||||
|
{
|
||||||
|
Report(inv, invocation: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Visit(inv.Expression, suppress: true);
|
||||||
|
foreach (var arg in inv.ArgumentList.Arguments)
|
||||||
|
{
|
||||||
|
Visit(arg.Expression, suppress: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ElementAccessExpressionSyntax elementAccess:
|
||||||
|
if (!suppress)
|
||||||
|
{
|
||||||
|
Report(elementAccess, invocation: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The indexed CONTAINER is covered by the access itself
|
||||||
|
// (same rule as the invocation callee above).
|
||||||
|
Visit(elementAccess.Expression, suppress: true);
|
||||||
|
foreach (var arg in elementAccess.ArgumentList.Arguments)
|
||||||
|
{
|
||||||
|
Visit(arg.Expression, suppress: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MemberAccessExpressionSyntax memberAccess:
|
||||||
|
if (!suppress)
|
||||||
|
{
|
||||||
|
Report(memberAccess, invocation: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The member NAME is part of this node; only the receiver
|
||||||
|
// is an independent read. Suppression never propagates
|
||||||
|
// here: it marks exactly ONE node (the direct callee), so
|
||||||
|
// `a.B.C(x)` still reports its `a.B` link.
|
||||||
|
Visit(memberAccess.Expression, suppress: false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MemberBindingExpressionSyntax binding:
|
||||||
|
// `a?.B`: the binding is the member access shape of a
|
||||||
|
// null-conditional chain. No children to visit (the name
|
||||||
|
// is part of this node).
|
||||||
|
if (!suppress)
|
||||||
|
{
|
||||||
|
Report(binding, invocation: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CastExpressionSyntax cast:
|
||||||
|
if (!suppress)
|
||||||
|
{
|
||||||
|
Report(cast, invocation: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Visit(cast.Expression, suppress: false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case IdentifierNameSyntax id:
|
||||||
|
if (!suppress)
|
||||||
|
{
|
||||||
|
ClassifyIdentifier(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ParenthesizedExpressionSyntax parenthesized:
|
||||||
|
// Parentheses must not change what "covered by the call"
|
||||||
|
// means: `(best.Bigger)(...)` keeps the callee covered.
|
||||||
|
Visit(parenthesized.Expression, suppress);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Generic descent (statement scaffolding, binaries,
|
||||||
|
// unary/conditional operators, interpolation holes, ...):
|
||||||
|
// none of these shapes is itself an extract-first
|
||||||
|
// candidate, but their operand expressions are.
|
||||||
|
foreach (var child in node.ChildNodes())
|
||||||
|
{
|
||||||
|
Visit(child, suppress: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var statement in selection.Statements)
|
||||||
|
{
|
||||||
|
Visit(statement, suppress: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the selection assigns to something that is not a local or a
|
||||||
|
/// parameter (a field, property, indexer, ...) — the decision-5 case the
|
||||||
|
/// report must flag, because the reads-only scan cannot see that write.
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasNonVariableAssignmentLeftSide(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
foreach (var statement in selection.Statements)
|
||||||
|
{
|
||||||
|
foreach (var node in statement.DescendantNodesAndSelf().OfType<AssignmentExpressionSyntax>())
|
||||||
|
{
|
||||||
|
var symbol = model.GetSymbolInfo(node.Left).Symbol;
|
||||||
|
if (symbol is not ILocalSymbol and not IParameterSymbol)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>Everything the extract-method report is built from: the resolved
|
||||||
|
/// selection, the raw classification buckets (em 02), the extract-first
|
||||||
|
/// candidates (em 03), the promoted signature line (em 03) and the v1
|
||||||
|
/// limitation notes. One record so the CLI and the tests compose EXACTLY the
|
||||||
|
/// same thing — the report cannot drift between them.</summary>
|
||||||
|
public sealed record ExtractionReport(
|
||||||
|
SelectionReport Selection,
|
||||||
|
ExtractionSuggestion Suggestion,
|
||||||
|
IReadOnlyList<ExtractFirstEntry> ExtractFirst,
|
||||||
|
SignatureSuggestion Signature,
|
||||||
|
IReadOnlyList<string> Notes);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes the full extraction suggestion for a resolved selection: the em 02
|
||||||
|
/// data-flow buckets, the em 03 extract-first scan, the promoted signature and
|
||||||
|
/// the limitation notes. Throws the same clean exceptions as
|
||||||
|
/// <see cref="DataFlowClassifier.Classify"/> when analysis cannot bind.
|
||||||
|
/// </summary>
|
||||||
|
public static class ExtractionReporter
|
||||||
|
{
|
||||||
|
public static ExtractionReport Compose(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
var suggestion = DataFlowClassifier.Classify(model, selection);
|
||||||
|
var extractFirst = ExtractFirstScanner.Scan(model, selection);
|
||||||
|
var signature = SignatureBuilder.Build(model, selection, suggestion);
|
||||||
|
return new ExtractionReport(selection, suggestion, extractFirst, signature, BuildNotes(model, selection));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The v1 limitation notes (parent decision #5 and the em 02 contract):
|
||||||
|
/// each note is printed only when its limitation actually applies to the
|
||||||
|
/// selection, so a clean selection gets a clean report.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<string> BuildNotes(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
var notes = new List<string>();
|
||||||
|
|
||||||
|
if (ExtractFirstScanner.HasNonVariableAssignmentLeftSide(model, selection))
|
||||||
|
{
|
||||||
|
notes.Add(
|
||||||
|
"reads-only scan (decision 5): assignment left-hand sides are skipped, " +
|
||||||
|
"so writes to fields/indexers/properties are invisible to this report");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DataFlowClassifier.CompositeTrailingReturn(selection) is { } trailing)
|
||||||
|
{
|
||||||
|
notes.Add(
|
||||||
|
$"the selection ends with a composite return expression ({trailing.Expression!.ToFullString().Trim()}) — " +
|
||||||
|
"extract it into a local first to make the return value nameable");
|
||||||
|
}
|
||||||
|
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The generated extract-method refactoring (yak em 04 codegen): the NEW
|
||||||
|
/// method (the em 03 promoted signature; parameter names come straight from
|
||||||
|
/// the source symbols), the CALL STATEMENTS that replace the selection in the
|
||||||
|
/// enclosing method, and the fully transformed syntax tree.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="NewMethod">The extracted method, ready to be read. Built
|
||||||
|
/// with SyntaxFactory and NORMALIZED (whitespace) before insertion, so its
|
||||||
|
/// text is the readable shape that lands in the file — not the trivia-less
|
||||||
|
/// builder output.</param>
|
||||||
|
/// <param name="CallStatements">The statement(s) that replace the selected
|
||||||
|
/// run at the call site (one statement, or a capture + destructure pair for
|
||||||
|
/// a mixed tuple return), normalized like <see cref="NewMethod"/>.</param>
|
||||||
|
/// <param name="TransformedTree">The whole input tree with the selection
|
||||||
|
/// moved out and the new method added next to the enclosing one — the
|
||||||
|
/// round-trip check compiles exactly this tree.</param>
|
||||||
|
public sealed record Refactoring(
|
||||||
|
MethodDeclarationSyntax NewMethod,
|
||||||
|
IReadOnlyList<StatementSyntax> CallStatements,
|
||||||
|
SyntaxTree TransformedTree)
|
||||||
|
{
|
||||||
|
public string TransformedText => TransformedTree.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emits the refactoring behind the em 03 suggestion.
|
||||||
|
///
|
||||||
|
/// WHY no identifier rewriting at all: the suggested parameter names ARE the
|
||||||
|
/// source variable names (em 02/03 promotion), so the moved statements bind
|
||||||
|
/// unchanged — every local/parameter read inside the selection is a parameter
|
||||||
|
/// of the new method, every variable declared inside moves with the body, and
|
||||||
|
/// the remaining reads are type members visible from the same class
|
||||||
|
/// (the new method is inserted into the SAME containing type). This is the
|
||||||
|
/// property the resolver (one method body, whole statements) and the
|
||||||
|
/// classifier (params bucket = exactly the local/parameter reads) jointly
|
||||||
|
/// guarantee; a generic SyntaxRewriter renaming pass would be dead weight in
|
||||||
|
/// v1. What IS generated structurally:
|
||||||
|
/// - the new method declaration (signature line, static-ness mirrored from
|
||||||
|
/// the enclosing method, body = selected statements + appended
|
||||||
|
/// <c>return ...</c> for return slots the selection did not already end
|
||||||
|
/// with a return of),
|
||||||
|
/// - the call statement(s) replacing the selection,
|
||||||
|
/// - the insertion of the new method right after the enclosing one.
|
||||||
|
///
|
||||||
|
/// CALL-SITE RULES (per promoted return slots, v1):
|
||||||
|
/// - void → a plain call expression statement. A ref parameter already flows
|
||||||
|
/// the value back (e.g. Accumulate: <c>Extract(ref acc, n);</c>).
|
||||||
|
/// - one slot DECLARED inside the selection → redeclared at the call site
|
||||||
|
/// from the result: <c>int total = Extract(limit);</c>.
|
||||||
|
/// - one slot declared OUTSIDE → plain assignment: <c>acc = Extract(...);</c>.
|
||||||
|
/// - several slots ALL declared inside → tuple deconstruction:
|
||||||
|
/// <c>var (x, y) = Extract(n);</c>.
|
||||||
|
/// - several slots, any declared outside → capture + ItemN assignments
|
||||||
|
/// (the result is evaluated ONCE, then assigned member-wise).
|
||||||
|
/// - the enclosing method's return type is non-void and the selection ends
|
||||||
|
/// the method → the call site must be <c>return Extract(...);</c> and the
|
||||||
|
/// suggested return type must equal the enclosing one (otherwise: refuse).
|
||||||
|
///
|
||||||
|
/// REFUSALS (loud, never a silent wrong refactoring):
|
||||||
|
/// - the selection contains a return that does NOT end the enclosing method
|
||||||
|
/// (v1: select to the end of the method);
|
||||||
|
/// - the suggested return type does not match the enclosing method's return
|
||||||
|
/// type — including the em 03 "composite trailing return" case, where the
|
||||||
|
/// signature is void although the enclosing method returns a value (the
|
||||||
|
/// report's note already says: extract a local first, then re-run).
|
||||||
|
///
|
||||||
|
/// BUILDER STYLE: single-slot declarations and assignments are hand-built
|
||||||
|
/// with SyntaxFactory; the deconstruction call <c>var (x, y) = ...;</c> and
|
||||||
|
/// the tuple return <c>return (x, y);</c> are PARSED from a text skeleton and
|
||||||
|
/// the call slot swapped in via ReplaceNode — the parser knows the
|
||||||
|
/// designation/tuple-element shapes a v1 builder would get wrong. (Parse
|
||||||
|
/// diagnostics would surface as red nodes and fail the round-trip check
|
||||||
|
/// loudly, not silently.)
|
||||||
|
///
|
||||||
|
/// KNOWN v1 APPROXIMATIONS: type display strings are re-parsed for the
|
||||||
|
/// generated declarations (a tuple return with a comma-bearing generic type
|
||||||
|
/// element is not supported); the capture name <c>result</c> could in theory
|
||||||
|
/// collide with an enclosing local; the name check "suggested return type ==
|
||||||
|
/// enclosing return type" is a string comparison, not symbol equality.
|
||||||
|
/// </summary>
|
||||||
|
public static class RefactoringGenerator
|
||||||
|
{
|
||||||
|
public static Refactoring Generate(SemanticModel model, SelectionReport selection, SignatureSuggestion signature)
|
||||||
|
{
|
||||||
|
var method = selection.Method!;
|
||||||
|
var body = method.Body!;
|
||||||
|
|
||||||
|
var declaredInside = SignatureBuilder.DeclaredInsideNames(model, selection);
|
||||||
|
var slots = signature.ReturnSlots.ToList();
|
||||||
|
var slotNames = slots.Select(s => s.Name).ToList();
|
||||||
|
|
||||||
|
var containsReturn = selection.Statements.OfType<ReturnStatementSyntax>().Any();
|
||||||
|
var selectionEndsBody = ReferenceEquals(selection.Statements[^1], body.Statements[^1]);
|
||||||
|
var enclosingNonVoid = method.ReturnType is not PredefinedTypeSyntax voidType
|
||||||
|
|| !voidType.Keyword.IsKind(SyntaxKind.VoidKeyword);
|
||||||
|
var enclosingReturnType = method.ReturnType.ToString();
|
||||||
|
|
||||||
|
// ---- soundness refusals (see class comment) ----
|
||||||
|
if (containsReturn && enclosingNonVoid)
|
||||||
|
{
|
||||||
|
if (!selectionEndsBody)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"the selection contains a return that does not end the enclosing method — select to the end of the method (v1 codegen)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signature.ReturnType != enclosingReturnType)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
signature.ReturnType == "void"
|
||||||
|
? "the selection ends with the method's return, but its value is not nameable in v1 — " +
|
||||||
|
"extract it into a local first (see the report note), then re-run"
|
||||||
|
: $"the suggested return type {signature.ReturnType} does not match the enclosing method's " +
|
||||||
|
$"return type {enclosingReturnType} — refusing to generate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the call expression (parameter names = source names, ref-ness kept) ----
|
||||||
|
var arguments = signature.Params
|
||||||
|
.Select(p =>
|
||||||
|
{
|
||||||
|
var argument = SyntaxFactory.Argument(SyntaxFactory.IdentifierName(p.Name));
|
||||||
|
return p.ByRef
|
||||||
|
? argument.WithRefOrOutKeyword(SyntaxFactory.Token(SyntaxKind.RefKeyword))
|
||||||
|
: argument;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var invocation = SyntaxFactory.InvocationExpression(
|
||||||
|
SyntaxFactory.IdentifierName(signature.MethodName))
|
||||||
|
.WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments)))
|
||||||
|
// Normalized once, here: the PARSED deconstruction skeleton (see
|
||||||
|
// BuildCallStatements) must not be re-normalized (the normalizer
|
||||||
|
// drops the space after the contextual keyword `var` before `(`),
|
||||||
|
// so it receives the invocation already properly spaced.
|
||||||
|
.NormalizeWhitespace();
|
||||||
|
|
||||||
|
// Normalizing only the GENERATED nodes keeps the original trivia —
|
||||||
|
// and therefore the doc comments — untouched (a whole-root
|
||||||
|
// NormalizeWhitespace corrupts doc-comment trivia in a way Roslyn's
|
||||||
|
// XML-doc writer rejects with CS1569, and it would also reformat the
|
||||||
|
// entire file, making the printed diff useless). The call statements
|
||||||
|
// and the new method get their indentation/end-of-line grafted from
|
||||||
|
// the selection they replace, so the edit slots into the original
|
||||||
|
// formatting.
|
||||||
|
var callStatements = BuildCallStatements(slots, slotNames, invocation, declaredInside,
|
||||||
|
callMustReturn: containsReturn && enclosingNonVoid).ToList();
|
||||||
|
var indent = SyntaxFactory.TriviaList(
|
||||||
|
selection.Statements[0].GetLeadingTrivia().Where(t => t.IsKind(SyntaxKind.WhitespaceTrivia)));
|
||||||
|
for (var index = 0; index < callStatements.Count; index++)
|
||||||
|
{
|
||||||
|
var call = callStatements[index].WithLeadingTrivia(indent);
|
||||||
|
if (index == callStatements.Count - 1)
|
||||||
|
{
|
||||||
|
// The replaced statement's trailing end-of-line kept the
|
||||||
|
// following token (the next statement or the closing brace)
|
||||||
|
// on its own line — carry it over.
|
||||||
|
call = call.WithTrailingTrivia(selection.Statements[^1].GetTrailingTrivia());
|
||||||
|
}
|
||||||
|
|
||||||
|
callStatements[index] = call;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the new method INSIDE a throwaway class wrapper: the
|
||||||
|
// normalizer computes indentation from nesting depth, and a bare
|
||||||
|
// method node has none — its body would come out flush-left. At class
|
||||||
|
// depth the signature lands one level in and the body two. The grafted
|
||||||
|
// leading/trailing trivia below replaces whatever the wrapper put
|
||||||
|
// around the member.
|
||||||
|
var newMethod = (MethodDeclarationSyntax)SyntaxFactory.ClassDeclaration("__normalizer__")
|
||||||
|
.WithMembers(SyntaxFactory.SingletonList<MemberDeclarationSyntax>(
|
||||||
|
BuildNewMethod(method, selection, signature, slots, slotNames)))
|
||||||
|
.NormalizeWhitespace()
|
||||||
|
.Members[0];
|
||||||
|
var methodIndent = method.GetLeadingTrivia()
|
||||||
|
.LastOrDefault(t => t.IsKind(SyntaxKind.WhitespaceTrivia));
|
||||||
|
newMethod = newMethod
|
||||||
|
.WithLeadingTrivia(SyntaxFactory.TriviaList(SyntaxFactory.EndOfLine("\n"), methodIndent))
|
||||||
|
.WithTrailingTrivia(SyntaxFactory.EndOfLine("\n"));
|
||||||
|
|
||||||
|
// ---- apply to the tree: swap the selection for the call, add the method ----
|
||||||
|
var allStatements = body.Statements;
|
||||||
|
var firstIndex = allStatements.IndexOf(selection.Statements[0]); // reference identity: same tree
|
||||||
|
var newStatements = new List<StatementSyntax>();
|
||||||
|
newStatements.AddRange(allStatements.Take(firstIndex));
|
||||||
|
newStatements.AddRange(callStatements);
|
||||||
|
newStatements.AddRange(allStatements.Skip(firstIndex + selection.Statements.Count));
|
||||||
|
var rewrittenMethod = method.WithBody(body.WithStatements(SyntaxFactory.List(newStatements)));
|
||||||
|
|
||||||
|
// The new method lands right after the enclosing one, in the SAME type
|
||||||
|
// (that is what keeps the extract-first own-class members in scope).
|
||||||
|
var containingType = method.AncestorsAndSelf().OfType<TypeDeclarationSyntax>().First();
|
||||||
|
var memberIndex = containingType.Members.IndexOf(method);
|
||||||
|
var newType = containingType.WithMembers(
|
||||||
|
containingType.Members.Replace(method, rewrittenMethod).Insert(memberIndex + 1, newMethod));
|
||||||
|
|
||||||
|
var tree = model.SyntaxTree;
|
||||||
|
var visited = new SingleNodeReplacer(containingType, newType).Visit(tree.GetRoot());
|
||||||
|
if (visited is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("the type replacement rewrote the root away (internal error)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Refactoring(newMethod, callStatements, tree.WithRootAndOptions(visited, tree.Options));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one place a SyntaxRewriter enters this tool (yak em 04's stated
|
||||||
|
/// curriculum): swapping a single node by REFERENCE identity. The typed
|
||||||
|
/// With*-slices (WithBody, WithMembers) cover the local changes; only the
|
||||||
|
/// "put the rewritten type back into the root" step is a whole-tree walk.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class SingleNodeReplacer : CSharpSyntaxRewriter
|
||||||
|
{
|
||||||
|
private readonly SyntaxNode _oldNode;
|
||||||
|
private readonly SyntaxNode _newNode;
|
||||||
|
|
||||||
|
public SingleNodeReplacer(SyntaxNode oldNode, SyntaxNode newNode)
|
||||||
|
{
|
||||||
|
_oldNode = oldNode;
|
||||||
|
_newNode = newNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override SyntaxNode? Visit(SyntaxNode? node)
|
||||||
|
=> ReferenceEquals(node, _oldNode) ? _newNode : base.Visit(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The call statement(s) replacing the selection (see class comment).</summary>
|
||||||
|
private static IReadOnlyList<StatementSyntax> BuildCallStatements(
|
||||||
|
IReadOnlyList<ReturnSuggestion> slots,
|
||||||
|
IReadOnlyList<string> slotNames,
|
||||||
|
InvocationExpressionSyntax invocation,
|
||||||
|
ISet<string> declaredInside,
|
||||||
|
bool callMustReturn)
|
||||||
|
{
|
||||||
|
if (callMustReturn)
|
||||||
|
{
|
||||||
|
// The selection ends the enclosing (non-void) method: the call is
|
||||||
|
// the return value, the slots live in the new method.
|
||||||
|
return [Norm(SyntaxFactory.ReturnStatement(invocation))];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slots.Count == 0)
|
||||||
|
{
|
||||||
|
return [Norm(SyntaxFactory.ExpressionStatement(invocation))];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slots.Count == 1)
|
||||||
|
{
|
||||||
|
var slot = slots[0];
|
||||||
|
if (declaredInside.Contains(slot.Name))
|
||||||
|
{
|
||||||
|
// Declared inside the selection: redeclare it at the call site
|
||||||
|
// from the result (the declaration statement moved with the body).
|
||||||
|
var declarator = SyntaxFactory.VariableDeclarator(slot.Name)
|
||||||
|
.WithInitializer(SyntaxFactory.EqualsValueClause(invocation));
|
||||||
|
var declaration = SyntaxFactory.VariableDeclaration(
|
||||||
|
ParseType(slot.Type), SyntaxFactory.SingletonSeparatedList(declarator));
|
||||||
|
return [Norm(SyntaxFactory.LocalDeclarationStatement(declaration))];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declared outside: the caller already owns it (a parameter, or a
|
||||||
|
// local declared before the selection) — plain assignment.
|
||||||
|
return [Norm(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression(
|
||||||
|
SyntaxKind.SimpleAssignmentExpression,
|
||||||
|
SyntaxFactory.IdentifierName(slot.Name),
|
||||||
|
invocation)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Several return slots.
|
||||||
|
if (slots.All(s => declaredInside.Contains(s.Name)))
|
||||||
|
{
|
||||||
|
// All declared inside: tuple deconstruction at the call site.
|
||||||
|
// Parsed from a skeleton (see class comment — builder style), the
|
||||||
|
// call swapped in at the placeholder identifier. NOT normalized:
|
||||||
|
// the skeleton text is already well-spaced, and NormalizeWhitespace
|
||||||
|
// would drop the space after the contextual keyword `var` (it is
|
||||||
|
// an identifier token to the normalizer, so `var (` becomes
|
||||||
|
// `var(`).
|
||||||
|
return [ParseStatementWithCall($"var ({string.Join(", ", slotNames)}) = __call__;", invocation)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixed: evaluate the call ONCE into a capture, then assign each slot
|
||||||
|
// from the matching ItemN. (Capture name collision with an enclosing
|
||||||
|
// local is a documented v1 limitation.)
|
||||||
|
var statements = new List<StatementSyntax>
|
||||||
|
{
|
||||||
|
Norm(SyntaxFactory.LocalDeclarationStatement(SyntaxFactory.VariableDeclaration(
|
||||||
|
SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VarKeyword)),
|
||||||
|
SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator("result")
|
||||||
|
.WithInitializer(SyntaxFactory.EqualsValueClause(invocation)))))),
|
||||||
|
};
|
||||||
|
for (var index = 0; index < slots.Count; index++)
|
||||||
|
{
|
||||||
|
statements.Add(Norm(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression(
|
||||||
|
SyntaxKind.SimpleAssignmentExpression,
|
||||||
|
SyntaxFactory.IdentifierName(slots[index].Name),
|
||||||
|
SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
|
||||||
|
SyntaxFactory.IdentifierName("result"),
|
||||||
|
SyntaxFactory.IdentifierName($"Item{index + 1}"))))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return statements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes a builder-made statement: SyntaxFactory tokens carry no
|
||||||
|
/// trivia, so without this <c>return</c> and <c>Extract</c> would render
|
||||||
|
/// glued together. Only used for BUILDER statements — the parsed skeleton
|
||||||
|
/// statements are already well-spaced (see the deconstruction comment).
|
||||||
|
/// </summary>
|
||||||
|
private static StatementSyntax Norm(StatementSyntax statement) => statement.NormalizeWhitespace();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses <paramref name="statementText"/> (containing the placeholder
|
||||||
|
/// identifier <c>__call__</c>) and swaps the placeholder for the built
|
||||||
|
/// <paramref name="invocation"/> — the parser supplies the syntax shapes
|
||||||
|
/// (tuple designations, tuple elements) the v1 builder avoids.
|
||||||
|
/// </summary>
|
||||||
|
private static StatementSyntax ParseStatementWithCall(string statementText, InvocationExpressionSyntax invocation)
|
||||||
|
{
|
||||||
|
var parsed = SyntaxFactory.ParseStatement(statementText);
|
||||||
|
var placeholder = parsed.DescendantNodes()
|
||||||
|
.OfType<IdentifierNameSyntax>()
|
||||||
|
.First(n => n.Identifier.ValueText == "__call__");
|
||||||
|
return parsed.ReplaceNode(placeholder, invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The extracted method: the promoted signature, static-ness mirrored from
|
||||||
|
/// the enclosing method, body = the selected statements plus an appended
|
||||||
|
/// <c>return</c> for the return slots (skipped when the selection already
|
||||||
|
/// ends in a return — a second one would be unreachable). The appended
|
||||||
|
/// tuple return is parsed from its text skeleton (see class comment).
|
||||||
|
/// </summary>
|
||||||
|
private static MethodDeclarationSyntax BuildNewMethod(
|
||||||
|
MethodDeclarationSyntax enclosingMethod,
|
||||||
|
SelectionReport selection,
|
||||||
|
SignatureSuggestion signature,
|
||||||
|
IReadOnlyList<ReturnSuggestion> slots,
|
||||||
|
IReadOnlyList<string> slotNames)
|
||||||
|
{
|
||||||
|
// A static enclosing method can only read statics and parameters, so
|
||||||
|
// the extracted body needs no instance — mirror the static modifier.
|
||||||
|
var modifiers = enclosingMethod.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword))
|
||||||
|
? new[] { SyntaxFactory.Token(SyntaxKind.StaticKeyword) }
|
||||||
|
: Array.Empty<SyntaxToken>();
|
||||||
|
|
||||||
|
var parameters = signature.Params
|
||||||
|
.Select(p =>
|
||||||
|
{
|
||||||
|
var parameter = SyntaxFactory.Parameter(SyntaxFactory.Identifier(p.Name))
|
||||||
|
.WithType(ParseType(p.Type));
|
||||||
|
return p.ByRef
|
||||||
|
? parameter.WithModifiers(SyntaxFactory.TokenList(
|
||||||
|
SyntaxFactory.Token(SyntaxKind.RefKeyword)))
|
||||||
|
: parameter;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var bodyStatements = selection.Statements.ToList();
|
||||||
|
if (slots.Count > 0 && selection.Statements[^1] is not ReturnStatementSyntax)
|
||||||
|
{
|
||||||
|
// The slot order here is the same name-ordered sequence the tuple
|
||||||
|
// ReturnType was built from, so the emitted value matches the
|
||||||
|
// declared element types.
|
||||||
|
bodyStatements.Add(slots.Count == 1
|
||||||
|
? SyntaxFactory.ReturnStatement(SyntaxFactory.IdentifierName(slotNames[0]))
|
||||||
|
: SyntaxFactory.ParseStatement($"return ({string.Join(", ", slotNames)});"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return SyntaxFactory.MethodDeclaration(ParseType(signature.ReturnType), SyntaxFactory.Identifier(signature.MethodName))
|
||||||
|
.WithModifiers(SyntaxFactory.TokenList(modifiers))
|
||||||
|
.WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(parameters)))
|
||||||
|
.WithBody(SyntaxFactory.Block(SyntaxFactory.List(bodyStatements)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-parses a type display string (em 02/03 are plain strings) in type
|
||||||
|
/// context — <c>ParseTypeName</c> handles plain names, generics and tuple
|
||||||
|
/// display strings <c>(T1, T2)</c> alike.
|
||||||
|
///
|
||||||
|
/// EXCEPT <c>void</c>: parsing "void" in TYPE context yields a node the
|
||||||
|
/// compiler rejects as a method return type with CS1547 (keyword 'void'
|
||||||
|
/// cannot be used in this context) — the parser that reads real method
|
||||||
|
/// declarations produces <c>PredefinedType(Token(VoidKeyword))</c>, so
|
||||||
|
/// that is what a void signature must be built from.
|
||||||
|
/// Single choke point for the string-to-syntax hop; its correctness is
|
||||||
|
/// enforced by the round-trip check and the tests, not by construction.
|
||||||
|
/// </summary>
|
||||||
|
private static TypeSyntax ParseType(string text) => text == "void"
|
||||||
|
? SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VoidKeyword))
|
||||||
|
: SyntaxFactory.ParseTypeName(text);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders an <see cref="ExtractionReport"/> as the human-readable extract-
|
||||||
|
/// method suggestion report (parent spec): params (with ref), return
|
||||||
|
/// candidates, locals, extract-first candidates (deduped, with occurrence
|
||||||
|
/// counts and flags), the limitation notes, and the suggested signature line
|
||||||
|
/// LAST. Pure string shaping — no Roslyn — so tests can pin the exact output
|
||||||
|
/// the CLI prints.
|
||||||
|
/// </summary>
|
||||||
|
public static class ReportFormatter
|
||||||
|
{
|
||||||
|
public static string Format(ExtractionReport report)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var selection = report.Selection;
|
||||||
|
var suggestion = report.Suggestion;
|
||||||
|
|
||||||
|
// Header (same wording the CLI printed since em 01).
|
||||||
|
sb.AppendLine(
|
||||||
|
$"{selection.Count} statement(s) selected, lines {selection.StartLine}..{selection.EndLine} " +
|
||||||
|
$"in {selection.Method?.Identifier.ValueText}(): " +
|
||||||
|
string.Join(", ", selection.Kinds));
|
||||||
|
|
||||||
|
AppendBucket(sb, "params", suggestion.Params.Select(p =>
|
||||||
|
$"{p.Name} ({p.Type}){(p.ByRef ? " [ref]" : " [in]")}"));
|
||||||
|
AppendBucket(sb, "returns", suggestion.Returns.Select(r => $"{r.Name} ({r.Type})"));
|
||||||
|
AppendBucket(sb, "locals", suggestion.Locals);
|
||||||
|
|
||||||
|
if (report.ExtractFirst.Count == 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine("extract-first: (none)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.AppendLine("extract-first:");
|
||||||
|
foreach (var entry in report.ExtractFirst)
|
||||||
|
{
|
||||||
|
// Flags accumulate; occurrence count is always shown because
|
||||||
|
// "count occurrences" is the point of the dedupe.
|
||||||
|
var flags = new List<string>();
|
||||||
|
if (entry.IsInvocation)
|
||||||
|
{
|
||||||
|
flags.Add("hoisting changes eval count");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Optional)
|
||||||
|
{
|
||||||
|
flags.Add($"optional: own-class {entry.OwnMemberKind}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var flagText = flags.Count > 0 ? $" [{string.Join("; ", flags)}]" : string.Empty;
|
||||||
|
sb.AppendLine($" - {entry.Text} — {entry.SymbolDisplay} ×{entry.Occurrences}{flagText}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var note in report.Notes)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"note: {note}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var signature = report.Signature;
|
||||||
|
var parameterList = string.Join(", ", signature.Params.Select(p => $"{(p.ByRef ? "ref " : "")}{p.Type} {p.Name}"));
|
||||||
|
sb.AppendLine($"suggested signature: {signature.ReturnType} {signature.MethodName}({parameterList})");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendBucket(StringBuilder sb, string name, IEnumerable<string> items)
|
||||||
|
{
|
||||||
|
var enumerated = items.ToList();
|
||||||
|
sb.AppendLine(enumerated.Count == 0 ? $"{name}: (none)" : $"{name}: {enumerated[0]}");
|
||||||
|
for (var i = 1; i < enumerated.Count; i++)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"{name}: {enumerated[i]}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>A line range resolved to the whole statements it covers.</summary>
|
||||||
|
public sealed record SelectionReport
|
||||||
|
{
|
||||||
|
public required bool Succeeded { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Human-readable reason when <see cref="Succeeded"/> is false.</summary>
|
||||||
|
public string? Error { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The selected whole statements, in source order, all siblings in one
|
||||||
|
/// statement list (v1: the enclosing method's body block).
|
||||||
|
/// </summary>
|
||||||
|
public required IReadOnlyList<StatementSyntax> Statements { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The method that owns the selected statements.</summary>
|
||||||
|
public required MethodDeclarationSyntax? Method { get; init; }
|
||||||
|
|
||||||
|
public int StartLine { get; init; }
|
||||||
|
public int EndLine { get; init; }
|
||||||
|
|
||||||
|
public int Count => Statements.Count;
|
||||||
|
|
||||||
|
/// <summary>Statement kinds (e.g. LocalDeclarationStatement, ForStatement).</summary>
|
||||||
|
public IEnumerable<SyntaxKind> Kinds => Statements.Select(s => s.Kind());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snap a 1-based, inclusive line range (IDE-selection style) to a contiguous
|
||||||
|
/// set of WHOLE statements — never split a statement (parent spec decision #1).
|
||||||
|
///
|
||||||
|
/// v1 rule: the selection is matched against the top-level statement list of
|
||||||
|
/// the enclosing method's body block. A range nested inside a block whose
|
||||||
|
/// siblings live deeper (e.g. the body of a for-loop) is a clean error: such
|
||||||
|
/// selections would not survive extraction anyway, so the tool tells the user
|
||||||
|
/// to widen the selection to the whole enclosing statement (the loop).
|
||||||
|
/// </summary>
|
||||||
|
public static class SelectionResolver
|
||||||
|
{
|
||||||
|
/// <summary>Line range of a missing selection (error path).</summary>
|
||||||
|
private static readonly SelectionReport Failure = new()
|
||||||
|
{
|
||||||
|
Succeeded = false,
|
||||||
|
Statements = Array.Empty<StatementSyntax>(),
|
||||||
|
Method = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Common ExitCode for a clean resolution error (see also 2 = usage).</summary>
|
||||||
|
public const int ExitError = 1;
|
||||||
|
|
||||||
|
public static SelectionReport Resolve(SyntaxTree tree, int startLine, int endLine)
|
||||||
|
{
|
||||||
|
var text = tree.GetText();
|
||||||
|
|
||||||
|
// ---- validate and normalize the requested line range ----
|
||||||
|
if (startLine < 1 || startLine > text.Lines.Count)
|
||||||
|
{
|
||||||
|
return Fail($"startLine {startLine} is out of range (file has {text.Lines.Count} lines)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endLine < startLine || endLine > text.Lines.Count)
|
||||||
|
{
|
||||||
|
return Fail($"endLine {endLine} is out of range (file has {text.Lines.Count} lines)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snap the endpoints past whitespace-only lines: an IDE "select lines"
|
||||||
|
// range often trails a blank line; blank lines are not statements, so
|
||||||
|
// skipping them is snapping, not splitting.
|
||||||
|
while (endLine > startLine && IsBlank(text.Lines[endLine - 1])) endLine--;
|
||||||
|
while (startLine < endLine && IsBlank(text.Lines[startLine - 1])) startLine++;
|
||||||
|
|
||||||
|
// Positions of the first / last non-whitespace character of the
|
||||||
|
// bounding lines. Trailing content (e.g. a `// comment` after the last
|
||||||
|
// statement) intentionally KEEPS the end position past the statement,
|
||||||
|
// which the boundary check below reports as "range ends mid-statement".
|
||||||
|
int selStart = FirstNonWhitespacePosition(text.Lines[startLine - 1]);
|
||||||
|
int selEnd = LastNonWhitespacePosition(text.Lines[endLine - 1]);
|
||||||
|
|
||||||
|
// ---- find the enclosing method ----
|
||||||
|
// Anchor on the first token of the selection: at statement start the
|
||||||
|
// token belongs to the statement we want (FindNode at exact boundaries
|
||||||
|
// is ambiguous, FindToken is not).
|
||||||
|
var firstToken = tree.GetRoot().FindToken(selStart);
|
||||||
|
var enclosingStatement = firstToken.Parent?.AncestorsAndSelf()
|
||||||
|
.OfType<StatementSyntax>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
var method = enclosingStatement?.AncestorsAndSelf()
|
||||||
|
.OfType<MethodDeclarationSyntax>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (method is null)
|
||||||
|
{
|
||||||
|
return Fail($"{startLine} does not sit inside a method (local functions and expression bodies are not supported in v1)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole selection must live inside ONE method body.
|
||||||
|
if (method.Body is null || selEnd > method.Body.FullSpan.End)
|
||||||
|
{
|
||||||
|
return Fail($"range {startLine}..{endLine} crosses a method boundary (selection must stay inside one method body)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- v1: only the method body's own statement list is selectable ----
|
||||||
|
if (enclosingStatement is not null && enclosingStatement.Parent != method.Body)
|
||||||
|
{
|
||||||
|
return Fail(
|
||||||
|
$"range {startLine}..{endLine} is nested inside \"{enclosingStatement.Kind()}\" — " +
|
||||||
|
"select the whole enclosing statement (e.g. the entire for-loop) instead");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- collect the whole statements covered by the range ----
|
||||||
|
var body = method.Body;
|
||||||
|
var selected = body.Statements
|
||||||
|
.Where(s => selStart <= s.SpanStart && s.Span.End <= selEnd)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (selected.Count == 0)
|
||||||
|
{
|
||||||
|
return Fail($"range {startLine}..{endLine} covers no whole top-level statement (mid-statement or empty range)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boundary checks: the selection must START at a statement's first
|
||||||
|
// token and END right after a statement's last token. Siblings never
|
||||||
|
// overlap, so equal boundaries also imply the selected run is
|
||||||
|
// contiguous (any statement between first and last lies inside the
|
||||||
|
// range and is therefore in `selected`).
|
||||||
|
if (selected[0].SpanStart != selStart)
|
||||||
|
{
|
||||||
|
return Fail($"range {startLine}..{endLine} starts inside a statement — snap to whole statements");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected[^1].Span.End != selEnd)
|
||||||
|
{
|
||||||
|
return Fail($"range {startLine}..{endLine} ends inside a statement (or picks up trailing text on the last line) — snap to whole statements");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SelectionReport
|
||||||
|
{
|
||||||
|
Succeeded = true,
|
||||||
|
Statements = selected,
|
||||||
|
Method = method,
|
||||||
|
StartLine = startLine,
|
||||||
|
EndLine = endLine,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SelectionReport Fail(string reason) => Failure with { Error = reason };
|
||||||
|
|
||||||
|
private static bool IsBlank(TextLine line) => string.IsNullOrWhiteSpace(line.ToString());
|
||||||
|
|
||||||
|
private static int FirstNonWhitespacePosition(TextLine line)
|
||||||
|
{
|
||||||
|
var s = line.ToString();
|
||||||
|
return line.Start + (s.Length - s.TrimStart().Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int LastNonWhitespacePosition(TextLine line)
|
||||||
|
{
|
||||||
|
var s = line.ToString();
|
||||||
|
return line.End - (s.Length - s.TrimEnd().Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>The suggested signature line of the extraction (parent spec:
|
||||||
|
/// "ends with a suggested signature: <c>int Extract(int i, Order order)</c>").</summary>
|
||||||
|
/// <param name="ReturnType">Return type, <c>void</c> when nothing must flow
|
||||||
|
/// out, or a tuple type when several declared-inside variables must.</param>
|
||||||
|
/// <param name="MethodName">v1 always suggests <c>Extract</c>.</param>
|
||||||
|
/// <param name="Params">The coherent parameter list (ref-ness preserved from
|
||||||
|
/// the classification).</param>
|
||||||
|
/// <param name="ReturnSlots">The individual return slot candidates (name +
|
||||||
|
/// type), name-ordered — the same order the tuple <see cref="ReturnType"/>
|
||||||
|
/// was built from, and the order em 04's codegen emits them in the appended
|
||||||
|
/// <c>return (...)</c> statement and the call-side declaration. Empty for a
|
||||||
|
/// void extraction.</param>
|
||||||
|
public sealed record SignatureSuggestion(
|
||||||
|
string ReturnType,
|
||||||
|
string MethodName,
|
||||||
|
IReadOnlyList<ParamSuggestion> Params,
|
||||||
|
IReadOnlyList<ReturnSuggestion> ReturnSlots);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns the raw em 02 buckets into ONE coherent signature line. The buckets
|
||||||
|
/// over-report on purpose (parent spec: a variable can sit in several
|
||||||
|
/// buckets); building a signature is where the overlaps are resolved — the
|
||||||
|
/// "promotion" em 02's comment promises.
|
||||||
|
///
|
||||||
|
/// RULES (v1):
|
||||||
|
/// - A variable DECLARED inside the selection cannot be a parameter of the
|
||||||
|
/// extracted method (it does not exist before the selection): drop it from
|
||||||
|
/// the params. If it is also a return candidate, it becomes the RETURN —
|
||||||
|
/// e.g. `total` (declared, reassigned, read after) in Summarize lands in
|
||||||
|
/// params[ref] ∧ returns raw, and promotes to the plain return value.
|
||||||
|
/// - A return candidate declared OUTSIDE the selection that the extraction
|
||||||
|
/// already receives by-ref (read inside ∧ written inside) keeps its ref
|
||||||
|
/// parameter: the write-back already flows the value out, and also
|
||||||
|
/// returning it would hand the caller the same value twice.
|
||||||
|
/// - A return candidate declared OUTSIDE that is NOT a param (written
|
||||||
|
/// inside, never read inside) becomes a return slot too: the caller
|
||||||
|
/// reassigns it from the extracted method's result.
|
||||||
|
/// - Trailing composite returns (`return a + b;`) are not nameable in v1 —
|
||||||
|
/// no return slot; the report's note suggests extracting a local first.
|
||||||
|
///
|
||||||
|
/// Determinism: parameters keep the classifier's ordinal-by-name order and
|
||||||
|
/// return slots are ordered by name, so the line is stable for tests.
|
||||||
|
/// </summary>
|
||||||
|
public static class SignatureBuilder
|
||||||
|
{
|
||||||
|
public static SignatureSuggestion Build(SemanticModel model, SelectionReport selection, ExtractionSuggestion suggestion)
|
||||||
|
{
|
||||||
|
var declaredInside = DeclaredInsideNames(model, selection);
|
||||||
|
var paramNames = suggestion.Params.Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var parameters = suggestion.Params
|
||||||
|
.Where(p => !declaredInside.Contains(p.Name))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var returnSlots = suggestion.Returns
|
||||||
|
.Where(r => declaredInside.Contains(r.Name) || !paramNames.Contains(r.Name))
|
||||||
|
.OrderBy(r => r.Name, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var returnType = returnSlots.Count switch
|
||||||
|
{
|
||||||
|
0 => "void",
|
||||||
|
1 => returnSlots[0].Type,
|
||||||
|
// Branch-insensitive over-approximation can over-report returns
|
||||||
|
// (decision #4); the tuple keeps every candidate flowing rather
|
||||||
|
// than guessing which is real.
|
||||||
|
_ => $"({string.Join(", ", returnSlots.Select(r => r.Type))})",
|
||||||
|
};
|
||||||
|
|
||||||
|
return new SignatureSuggestion(returnType, "Extract", parameters, returnSlots);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Names of the locals DECLARED inside the selection. v1 covers the
|
||||||
|
/// declaration shapes the fixture uses — local declarations, for-loop
|
||||||
|
/// headers (`for (int i = ...)`) and foreach headers; other declaring
|
||||||
|
/// shapes (pattern declarations, using/fixed statements, local functions)
|
||||||
|
/// are not selected by the v1 resolver and would need their own cases.
|
||||||
|
///
|
||||||
|
/// Names, not symbols, are matched against the bucket entries — sound
|
||||||
|
/// here because C# forbids a local from shadowing another local or a
|
||||||
|
/// parameter of the same method, so names are unique per method.
|
||||||
|
/// </summary>
|
||||||
|
public static HashSet<string> DeclaredInsideNames(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var statement in selection.Statements)
|
||||||
|
{
|
||||||
|
foreach (var node in statement.DescendantNodesAndSelf())
|
||||||
|
{
|
||||||
|
switch (node)
|
||||||
|
{
|
||||||
|
case LocalDeclarationStatementSyntax localDeclaration:
|
||||||
|
foreach (var variable in localDeclaration.Declaration.Variables)
|
||||||
|
{
|
||||||
|
if (model.GetDeclaredSymbol(variable) is ILocalSymbol)
|
||||||
|
{
|
||||||
|
names.Add(variable.Identifier.ValueText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ForStatementSyntax forStatement when forStatement.Declaration is not null:
|
||||||
|
foreach (var variable in forStatement.Declaration.Variables)
|
||||||
|
{
|
||||||
|
if (model.GetDeclaredSymbol(variable) is ILocalSymbol)
|
||||||
|
{
|
||||||
|
names.Add(variable.Identifier.ValueText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ForEachStatementSyntax forEach:
|
||||||
|
if (model.GetDeclaredSymbol(forEach) is ILocalSymbol)
|
||||||
|
{
|
||||||
|
names.Add(forEach.Identifier.ValueText);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Line-based unified diff (v1, hand-rolled, no dependencies): an LCS over
|
||||||
|
/// lines is backtracked into equal/delete/insert runs, and the runs are
|
||||||
|
/// printed as unified hunks with 3 lines of context (hunks closer than
|
||||||
|
/// 2×context lines apart merge). The shape is the familiar
|
||||||
|
/// <c>---/+++/@@</c> one — enough for a human to review the generated
|
||||||
|
/// refactoring; no patch-file semantics are promised.
|
||||||
|
/// </summary>
|
||||||
|
public static class UnifiedDiff
|
||||||
|
{
|
||||||
|
private const int Context = 3;
|
||||||
|
|
||||||
|
public static string Diff(string original, string modified, string originalLabel, string modifiedLabel)
|
||||||
|
{
|
||||||
|
var a = SplitLines(original);
|
||||||
|
var b = SplitLines(modified);
|
||||||
|
|
||||||
|
var sb = new StringBuilder($"--- {originalLabel}\n+++ {modifiedLabel}\n");
|
||||||
|
foreach (var hunk in BuildHunks(a, b))
|
||||||
|
{
|
||||||
|
sb.Append(hunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Split on newlines, dropping a trailing carriage return (CRLF tolerance).
|
||||||
|
/// A trailing newline does NOT start a new line: "a\n" is ONE line, so the
|
||||||
|
/// phantom empty element a naive Split('\n') leaves at the end is dropped
|
||||||
|
/// (it would inflate every hunk header by one). The empty string is zero
|
||||||
|
/// lines.
|
||||||
|
/// </summary>
|
||||||
|
private static string[] SplitLines(string text)
|
||||||
|
{
|
||||||
|
if (text.Length == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = text.Split('\n').Select(line => line.TrimEnd('\r')).ToArray();
|
||||||
|
return lines[^1].Length == 0 ? lines[..^1] : lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Op { Equal, Del, Ins }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LCS + backtracking + hunk assembly. <c>lcs[i, j]</c> is the length of
|
||||||
|
/// the longest common suffix of <c>a[i..]</c> and <c>b[j..]</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<string> BuildHunks(string[] a, string[] b)
|
||||||
|
{
|
||||||
|
var n = a.Length;
|
||||||
|
var m = b.Length;
|
||||||
|
|
||||||
|
var lcs = new int[n + 1, m + 1];
|
||||||
|
for (var row = n - 1; row >= 0; row--)
|
||||||
|
{
|
||||||
|
for (var col = m - 1; col >= 0; col--)
|
||||||
|
{
|
||||||
|
lcs[row, col] = a[row] == b[col]
|
||||||
|
? lcs[row + 1, col + 1] + 1
|
||||||
|
: Math.Max(lcs[row + 1, col], lcs[row, col + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backtrack: equal lines advance both; on a mismatch prefer a deletion
|
||||||
|
// (ties go to delete, which keeps the printed block old-then-new).
|
||||||
|
var ops = new List<(Op Tag, int A, int B)>();
|
||||||
|
var i = 0;
|
||||||
|
var j = 0;
|
||||||
|
while (i < n && j < m)
|
||||||
|
{
|
||||||
|
if (a[i] == b[j])
|
||||||
|
{
|
||||||
|
ops.Add((Op.Equal, i, j));
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
else if (lcs[i + 1, j] >= lcs[i, j + 1])
|
||||||
|
{
|
||||||
|
ops.Add((Op.Del, i, j));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ops.Add((Op.Ins, i, j));
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < n)
|
||||||
|
{
|
||||||
|
ops.Add((Op.Del, i, j));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (j < m)
|
||||||
|
{
|
||||||
|
ops.Add((Op.Ins, i, j));
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A "region" is a maximal run of non-equal ops: [AStart, AEnd) ×
|
||||||
|
// [BStart, BEnd) in the two line arrays.
|
||||||
|
var regions = new List<(int AStart, int AEnd, int BStart, int BEnd)>();
|
||||||
|
var index = 0;
|
||||||
|
while (index < ops.Count)
|
||||||
|
{
|
||||||
|
if (ops[index].Tag == Op.Equal)
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = ops[index];
|
||||||
|
var regionAStart = first.Tag == Op.Del ? first.A : (index > 0 ? ops[index - 1].A + 1 : 0);
|
||||||
|
var regionBStart = first.B;
|
||||||
|
while (index < ops.Count && ops[index].Tag != Op.Equal)
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var last = ops[index - 1];
|
||||||
|
regions.Add((regionAStart,
|
||||||
|
last.Tag == Op.Del ? last.A + 1 : last.A,
|
||||||
|
regionBStart,
|
||||||
|
last.Tag == Op.Ins ? last.B + 1 : last.B));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend every region by Context lines (clamped), then merge regions
|
||||||
|
// whose extended ranges overlap.
|
||||||
|
var hunks = new List<(int AStart, int AEnd, int BStart, int BEnd)>();
|
||||||
|
foreach (var (aStart, aEnd, bStart, bEnd) in regions)
|
||||||
|
{
|
||||||
|
var (haStart, haEnd) = (Math.Max(0, aStart - Context), Math.Min(n, aEnd + Context));
|
||||||
|
var (hbStart, hbEnd) = (Math.Max(0, bStart - Context), Math.Min(m, bEnd + Context));
|
||||||
|
if (hunks.Count > 0)
|
||||||
|
{
|
||||||
|
var prev = hunks[^1];
|
||||||
|
if (haStart <= prev.AEnd && hbStart <= prev.BEnd)
|
||||||
|
{
|
||||||
|
hunks[^1] = (prev.AStart, Math.Max(prev.AEnd, haEnd), prev.BStart, Math.Max(prev.BEnd, hbEnd));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hunks.Add((haStart, haEnd, hbStart, hbEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
return hunks.Select(hunk => RenderHunk(ops, a, b, hunk.AStart, hunk.AEnd, hunk.BStart, hunk.BEnd)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RenderHunk(
|
||||||
|
IReadOnlyList<(Op Tag, int A, int B)> ops,
|
||||||
|
string[] a,
|
||||||
|
string[] b,
|
||||||
|
int aStart,
|
||||||
|
int aEnd,
|
||||||
|
int bStart,
|
||||||
|
int bEnd)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"@@ -{aStart + 1},{aEnd - aStart} +{bStart + 1},{bEnd - bStart} @@");
|
||||||
|
foreach (var (tag, aIndex, bIndex) in ops)
|
||||||
|
{
|
||||||
|
switch (tag)
|
||||||
|
{
|
||||||
|
case Op.Equal when aStart <= aIndex && aIndex < aEnd:
|
||||||
|
sb.Append(' ').AppendLine(a[aIndex]);
|
||||||
|
break;
|
||||||
|
case Op.Del when aStart <= aIndex && aIndex < aEnd:
|
||||||
|
sb.Append('-').AppendLine(a[aIndex]);
|
||||||
|
break;
|
||||||
|
case Op.Ins when bStart <= bIndex && bIndex < bEnd:
|
||||||
|
sb.Append('+').AppendLine(b[bIndex]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user