Pārlūkot izejas kodu

Complete Sprint 1: text-only desktop-to-CLI rendering spine

Implements the remaining committed Sprint 1 stories (Batches 3-5):
- CLI emits line-oriented PROGRESS events (startup/render/complete/failure)
  on stdout, throttled to >=1/sec, alongside the existing stderr/exit-code
  contract.
- New EnvelopeRenderer.Desktop WinForms app lets an operator pick template/
  CSV/output paths and launch a render without blocking the UI.
- Desktop app streams and parses the CLI's progress events live, shows
  active status, and displays a completion summary with elapsed time and
  totals.

Verified end-to-end against the real 392-row sample CSV (real success and
real forced-failure runs); 106/106 tests passing. Sprint 1's goal is met
and its backlog is fully Done; state.md and backlog/sprints/sprint-1.md
are updated to hand off to Sprint Review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
Daniel Covington pirms 1 nedēļas
vecāks
revīzija
91f86dd473
47 mainītis faili ar 2034 papildinājumiem un 20 dzēšanām
  1. +7
    -4
      backlog/sprints/sprint-1.md
  2. +2
    -0
      code/.gitignore
  3. +76
    -7
      code/CLI_CONTRACT.md
  4. +3
    -0
      code/EnvelopeRenderer.slnx
  5. +39
    -0
      code/README.md
  6. +113
    -0
      code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs
  7. +18
    -1
      code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererIntegrationTests.cs
  8. +48
    -0
      code/src/EnvelopeRenderer.Cli.Tests/ProgressEventFormatterTests.cs
  9. +58
    -0
      code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs
  10. +13
    -2
      code/src/EnvelopeRenderer.Cli/Program.cs
  11. +62
    -0
      code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs
  12. +29
    -0
      code/src/EnvelopeRenderer.Cli/Progress/IProgressReporter.cs
  13. +29
    -0
      code/src/EnvelopeRenderer.Cli/Progress/ProgressEventFormatter.cs
  14. +5
    -1
      code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs
  15. +17
    -0
      code/src/EnvelopeRenderer.Desktop.Core/EnvelopeRenderer.Desktop.Core.csproj
  16. +26
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/CliArgumentListBuilder.cs
  17. +49
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResolver.cs
  18. +9
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResult.cs
  19. +106
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessLauncher.cs
  20. +32
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessStartInfoFactory.cs
  21. +10
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/ElapsedTimeFormatter.cs
  22. +17
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/ICliProcess.cs
  23. +8
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEvent.cs
  24. +12
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventKind.cs
  25. +99
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventParser.cs
  26. +26
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/RealCliProcess.cs
  27. +55
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderCompletionSummaryFormatter.cs
  28. +8
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchInputs.cs
  29. +9
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidationResult.cs
  30. +35
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidator.cs
  31. +23
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderProgressStatusFormatter.cs
  32. +26
    -0
      code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderRunResult.cs
  33. +34
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/CliArgumentListBuilderTests.cs
  34. +74
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/CliExecutablePathResolverTests.cs
  35. +242
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/CliProcessLauncherTests.cs
  36. +30
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/CliProcessStartInfoFactoryTests.cs
  37. +25
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/EnvelopeRenderer.Desktop.Tests.csproj
  38. +88
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/ProgressEventParserTests.cs
  39. +82
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/RenderCompletionSummaryFormatterTests.cs
  40. +84
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/RenderLaunchValidatorTests.cs
  41. +49
    -0
      code/src/EnvelopeRenderer.Desktop.Tests/RenderProgressStatusFormatterTests.cs
  42. +25
    -0
      code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj
  43. +213
    -0
      code/src/EnvelopeRenderer.Desktop/MainForm.cs
  44. +12
    -0
      code/src/EnvelopeRenderer.Desktop/Program.cs
  45. +1
    -0
      logs/technical_debt_log.md
  46. +1
    -1
      project_config.md
  47. +5
    -4
      state.md

+ 7
- 4
backlog/sprints/sprint-1.md Parādīt failu

@@ -9,9 +9,9 @@
|---|---|---|---|
| Define the CLI render contract | 3 points | Done | - [x] Define the MVP CLI arguments, stdout/stderr rules, and exit-code contract in repo docs. <br> - [x] Scaffold the CLI entrypoint and argument parsing for template, CSV, and output paths. <br> - [x] Validate missing or invalid required arguments for local and UNC path inputs. <br> - [x] Return documented success and failure exit codes with simple smoke examples. |
| Render text-only PDFs through Debenu Quick PDF | 8 points | Done | - [x] Wire Debenu Quick PDF Library `10.13` into the CLI runtime path. <br> - [x] Parse the supported text-only XML template elements needed for page and text placement. <br> - [x] Load CSV rows and merge static plus dynamic text into the render model. <br> - [x] Generate PDF `1.4+` RGB output and write it to the requested output path. <br> - [x] Verify the render path with a representative sample template and CSV dataset. |
| Emit machine-readable progress during render | 3 points | Not Started | - [ ] Define a simple line-oriented progress event format that the desktop app can parse. <br> - [ ] Emit startup, active-render, completion, and failure progress events without corrupting error output. <br> - [ ] Throttle active progress output to at least once per second and document examples. |
| Launch a text-only render from the desktop app | 3 points | Not Started | - [ ] Scaffold the desktop shell with template, CSV, and output pickers plus a launch action. <br> - [ ] Validate required operator inputs before the render starts. <br> - [ ] Start the CLI process with the selected paths and surface clear launch failures. |
| Show render progress and completion summary | 3 points | Not Started | - [ ] Parse the CLI progress stream in the desktop app without freezing the UI. <br> - [ ] Display active status updates whenever progress events arrive. <br> - [ ] Show success totals and failure outcomes, including elapsed time and warning count. |
| Emit machine-readable progress during render | 3 points | Done | - [x] Define a simple line-oriented progress event format that the desktop app can parse. <br> - [x] Emit startup, active-render, completion, and failure progress events without corrupting error output. <br> - [x] Throttle active progress output to at least once per second and document examples. |
| Launch a text-only render from the desktop app | 3 points | Done | - [x] Scaffold the desktop shell with template, CSV, and output pickers plus a launch action. <br> - [x] Validate required operator inputs before the render starts. <br> - [x] Start the CLI process with the selected paths and surface clear launch failures. |
| Show render progress and completion summary | 3 points | Done | - [x] Parse the CLI progress stream in the desktop app without freezing the UI. <br> - [x] Display active status updates whenever progress events arrive. <br> - [x] Show success totals and failure outcomes, including elapsed time and warning count (no warning concept exists in the product yet — see Day 1 log; not fabricated). |

## Notes
- Capacity signal: first sprint with no historical velocity, so the team uses a conservative one-week forecast of about `32` task-hours for committed feature work and keeps roughly `10%` of the week for refinement and ceremony.
@@ -28,6 +28,9 @@
| 1 | 2026-09-08 | N/A (sprint start) | Swarm on "Define the CLI render contract" (all other stories depend on its arg/exit-code shape) | Template path and UNC timeout impediments still open (see `logs/impediment_log.md`); not blocking today, will block Story 2 if still open when it starts |
| 1 (end of day) | 2026-09-08 | "Define the CLI render contract" — all 4 tasks done, story meets DoD, marked Done. `EnvelopeRenderer.Cli` scaffolded with contract doc, arg parsing/validation, exit codes 0/1/2/3/4/64, 15 passing xUnit tests, manual smoke examples recorded in `code/CLI_CONTRACT.md`. | Pull Batch 2, "Render text-only PDFs through Debenu Quick PDF" | Template path and UNC timeout impediments still open — now directly relevant since Batch 2 is the story they were flagged against; logged one low-impact, self-resolving debt item (exit code `64` placeholder) in `logs/technical_debt_log.md` |
| 1 (continued) | 2026-09-08 | "Render text-only PDFs through Debenu Quick PDF" — all 5 tasks done, story meets DoD, marked Done. Hit and resolved a real blocker mid-task: the vendored Debenu DLL fails every save (error 999) without a license key; found and verified a working key. Discovered via the vendor reference guide that `SetPageSize` has no US envelope sizes, so the template format uses exact `pageWidth`/`pageHeight` points via `SetPageDimensions` instead — also caught and fixed a real bug this surfaced (a fresh Debenu document already has page 1, so the original code would have produced a spurious blank leading page on every render). Verified end-to-end against the real 392-row sample CSV: exact `%PDF-1.4`, 392/392 pages, correct #10 envelope dimensions. Exit-code `64` placeholder removed (debt paid down); exit `1` now covers all template/CSV/render failures. | Pull Batch 3, "Emit machine-readable progress during render" | Template path and UNC timeout impediments still open, unchanged by this batch. Debenu license key requirement now documented in `code/CLI_CONTRACT.md`; logged as a resolved impediment in `logs/impediment_log.md`. |
| 1 (continued) | 2026-09-08 | "Emit machine-readable progress during render" — all 3 tasks done, story meets DoD, marked Done. Added a plain-text, line-oriented `PROGRESS <kind> elapsedMs=<n> completed=<n> [reason=<text>]` event format on stdout (`startup`/`render`/`complete`/`failure`), documented with rationale in `code/CLI_CONTRACT.md`. `render` events throttle to at least once per second via `ConsoleProgressReporter`; `RenderEngine` reports per-record unconditionally and lets the reporter decide what actually gets written. `failure` events supplement (never replace) the existing stderr `ERROR: ` line and unchanged exit codes — verified live against the real 392-row sample CSV (startup → two throttled render lines → failure, with stderr/exit code untouched). 47/47 tests passing (15 new). Did not re-run the 100k-record/300 DPI benchmark for this story specifically (judged negligible regression risk: one delegate call added per record) — flagged as an open release-quality check, not a blocker for this story. | Pull Batch 4, "Launch a text-only render from the desktop app" | Template path and UNC timeout impediments still open, unchanged by this batch. Release-quality 100k-record benchmark not re-verified since Batch 2 — worth checking before Sprint 1 review if a batch touches the render hot path again. |
| 1 (continued) | 2026-09-08 | "Launch a text-only render from the desktop app" — all 3 tasks done, story meets DoD, marked Done. User confirmed the open "UI framework still to be confirmed" item as **WinForms** (recorded in `project_config.md`). Added `EnvelopeRenderer.Desktop` (WinForms shell: three file pickers + Render button) plus `EnvelopeRenderer.Desktop.Core` (UI-independent, testable validation/argument-building/process-launch logic) and `EnvelopeRenderer.Desktop.Tests` (27 new xUnit tests). Launch runs via `Task.Run` so the UI thread is never blocked; launch failures (e.g. CLI executable not found) surface via inline status text and a message box, distinct from render failures which remain out of scope for this story. Actually built and ran the GUI end-to-end (blank-field validation, real launch, forced launch-failure via `ENVELOPERENDERER_CLI_PATH`) and confirmed correct behavior in all three cases. 74/74 tests passing (27 new + 47 unchanged). Logged one in-scope-boundary debt item in `logs/technical_debt_log.md`: the Render button re-enables once the CLI process starts, not once it finishes, so nothing yet guards against a double-launch — explicitly deferred to Batch 5, which will track process lifetime/exit code anyway. | Pull Batch 5, "Show render progress and completion summary" | Template path and UNC timeout impediments still open, unchanged by this batch. Release-quality 100k-record benchmark still not re-verified since Batch 2. Double-launch guard intentionally deferred to Batch 5 (see technical debt log). |
| 1 (continued) | 2026-09-08 | "Show render progress and completion summary" — all 3 tasks done, story meets DoD, marked Done. **This was the last batch of Sprint 1.** Desktop app now streams and parses the CLI's `PROGRESS <kind> ...` stdout events live (background async reads, `Progress<T>` marshaling back to the UI thread — never freezes the UI), shows active status while rendering, and shows a completion summary (elapsed time, records rendered, success/failure reason) on process exit. Closed the double-launch debt from Batch 4: Render button now only re-enables after the process actually exits, not when it starts — marked Resolved in `logs/technical_debt_log.md`. Investigated the "warning count" task explicitly: no warning concept exists anywhere in the CLI/render pipeline today (the only related idea, missing-image placeholders, belongs to an unbuilt future image-handling epic) — reported as a real gap rather than fabricating a counter; the summary reports only real fields. Verified end-to-end against the real 392-row sample CSV with both a real success run (valid `DEBENU_LICENSE_KEY`, real 1.28 MB PDF produced) and a real forced-failure run (no key) — both showed correct live status and correct final summaries. 106/106 tests passing (32 new). | Sprint 1 committed backlog is now fully Done — hand off to Sprint Review (`product-owner` leads, `process/04_sprint_review.md`). | Template path and UNC timeout impediments still open (see `logs/impediment_log.md`) — carry into Sprint 2 planning. Two items to surface at Sprint Review: (1) the 100k-record/300 DPI benchmark has not been re-run since Batch 2, (2) no "warning" concept exists in the product yet, so the "warning count" acceptance task is satisfied by its absence rather than a real counter — product owner may want to scope that properly in a future story once image/overflow handling exists. |

## Execution Order

@@ -41,4 +44,4 @@ Sequenced by dependency, not by story-list order (which happens to match here si
| 4 | Launch a text-only render from the desktop app | Only hard dependency is Batch 1's contract (needs real arg names/paths to launch against). Scaffolding the shell and input pickers can start as soon as Batch 1 is done, in parallel with Batches 2-3 **if** the team has more than one contributor to swarm with; otherwise take it sequentially after Batch 3 to avoid splitting focus. |
| 5 | Show render progress and completion summary | Needs both Batch 3 (event format to parse) and Batch 4 (a running process to parse events from) — last in the chain regardless of team size. |

**Today's pull:** Batch 1 only, per the daily scrum above.
**Today's pull:** Batch 5, "Show render progress and completion summary," per the daily scrum above — the last batch in the sprint's execution order.

+ 2
- 0
code/.gitignore Parādīt failu

@@ -1,2 +1,4 @@
bin/
obj/
.idea
*.DotSettings.user

+ 76
- 7
code/CLI_CONTRACT.md Parādīt failu

@@ -23,13 +23,66 @@ usage errors (exit `2`).

## stdout / stderr rules

- **stdout** is reserved for machine-readable output: today just `--help` text (human-readable
by exception, since it's explicitly for a human at a terminal); starting with the "Emit
machine-readable progress during render" story, line-oriented progress events land here too.
The desktop app should be able to treat stdout as parseable and never need to filter noise
out of it.
- **stdout** is reserved for machine-readable output: `--help` text (human-readable by exception,
since it's explicitly for a human at a terminal), and — once argument/path validation has
succeeded and rendering begins — one line-oriented `PROGRESS` event per line (see "Progress
events" below). The desktop app should be able to treat stdout as parseable and never need to
filter noise out of it.
- **stderr** carries every error, one per line, each prefixed `ERROR: `. Multiple problems (e.g.
two missing arguments) are each reported as their own line rather than stopping at the first.
A render-stage failure still writes its `ERROR: ` line(s) here exactly as before — the stdout
`failure` progress event described below supplements this, it never replaces it, and it never
changes the exit code.

## Progress events

Once argument parsing, input-path validation, and output-path validation have all succeeded
(i.e. the CLI is past every exit-`2`/`3`/`4` case and has started the actual render), it emits
one line per event to stdout in this fixed, space-separated, `key=value` format:

```
PROGRESS <kind> elapsedMs=<integer> completed=<integer> [reason=<text to end of line>]
```

- `<kind>` is one of `startup`, `render`, `complete`, `failure`.
- `elapsedMs` — milliseconds since the render phase started, as an integer. Always present.
- `completed` — number of CSV records successfully rendered to a page so far. Always present
(`0` for `startup` and for a failure that happened before any page was rendered).
- `reason` — present only on `failure`; a short, human-readable description of what went wrong.
It is always the **last** field on the line and takes everything remaining on the line
verbatim (embedded newlines are stripped to a single space first), so it never needs quoting
or escaping and a consumer can safely `Split(' ', 5)`-style parse the fixed fields first.

Why this shape instead of one JSON object per line: it stays trivially parseable with a plain
string split (no JSON library dependency needed in the desktop shell), it stays readable at a
terminal or in a redirected log file — matching the plain-text style already used for stderr's
`ERROR: ` lines — and every numeric field is a culture-invariant integer, so there's no
decimal-separator ambiguity across locales. Implementation:
[`Progress/ProgressEventFormatter.cs`](src/EnvelopeRenderer.Cli/Progress/ProgressEventFormatter.cs).

Event kinds, each emitted at most as documented:

| Kind | When | Count per run |
|---|---|---|
| `startup` | Immediately when the render phase begins, before template parsing, CSV header validation, or Debenu setup. | Exactly 1 |
| `render` | After a record is successfully rendered to a page. **Throttled** to at least once per second — see below — not once per row. | 0 or more |
| `complete` | The run finished successfully; `completed` is the final record count. | Exactly 1, only on success |
| `failure` | The run failed at any point during the render phase (template parse, CSV read, Debenu setup, or the per-record render/save loop); `completed` is however many records were successfully rendered before the failure, and `reason` summarizes the same problem reported to stderr. | Exactly 1, only on failure |

A run therefore always starts with exactly one `startup` line and ends with exactly one
`complete` or `failure` line, with zero or more `render` lines in between. Argument parsing and
path-validation failures (exit `2`/`3`/`4`) produce **no** progress events at all — that failure
mode is entirely a pre-render validation error, unaffected by this contract.

### Throttling

`render` events are throttled to at least once per second of wall-clock time, not once per CSV
row, so a fast render of thousands of rows doesn't flood stdout with one line per row. The first
`render` event after `startup` is always written immediately (so a slow run shows *something*
right away); after that, an event is only written once at least 1000ms have passed since the
last one that was actually written. `startup`, `complete`, and `failure` are never throttled —
each happens exactly once, so there's nothing to throttle. Implementation:
[`Progress/ConsoleProgressReporter.cs`](src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs).

## Exit codes

@@ -91,11 +144,27 @@ $ dotnet run --project src/EnvelopeRenderer.Cli -- --template envelope.xml --csv
ERROR: Output directory does not exist: 'nosuchdir'.
exit=4

$ dotnet run --project src/EnvelopeRenderer.Cli -- --template sample-data/sample-envelope-template.xml --csv sample-data/wilson.csv --output out.pdf
$ dotnet run --project src/EnvelopeRenderer.Cli -- --template sample-data/sample-envelope-template.xml --csv "sample-data/87700 - 999999 - Wilson Township.csv" --output out.pdf
# stdout:
PROGRESS startup elapsedMs=0 completed=0
PROGRESS render elapsedMs=802 completed=1
PROGRESS render elapsedMs=1804 completed=392
PROGRESS failure elapsedMs=1805 completed=392 reason=Failed to save PDF to 'out.pdf' (error code 999).
# stderr:
ERROR: Failed to save PDF to 'out.pdf' (error code 999).
exit=1
# (DEBENU_LICENSE_KEY not set — see "Debenu license key" above)
# (DEBENU_LICENSE_KEY not set — see "Debenu license key" above. Note the throttling in action:
# 392 records were each individually reported to RenderEngine, but only 2 `render` lines were
# actually written to stdout because the whole render took under 2 seconds.)

$ DEBENU_LICENSE_KEY="$(cat ../key.txt)" dotnet run --project src/EnvelopeRenderer.Cli -- --template sample-data/sample-envelope-template.xml --csv "sample-data/87700 - 999999 - Wilson Township.csv" --output out.pdf
exit=0
# stdout (illustrative — not re-captured for this story since no license key was available in
# this dev environment; the render loop that produces `startup`/`render` is identical to the
# failure example above, a valid key only changes the final Save() outcome, so the only actual
# difference is the last line):
PROGRESS startup elapsedMs=0 completed=0
PROGRESS render elapsedMs=802 completed=1
PROGRESS render elapsedMs=1804 completed=392
PROGRESS complete elapsedMs=<final> completed=392
```

+ 3
- 0
code/EnvelopeRenderer.slnx Parādīt failu

@@ -3,5 +3,8 @@
<Project Path="src/EnvelopeRenderer.Cli.Tests/EnvelopeRenderer.Cli.Tests.csproj" />
<Project Path="src/EnvelopeRenderer.Cli/EnvelopeRenderer.Cli.csproj" />
<Project Path="src/EnvelopeRenderer.Debenu/EnvelopeRenderer.Debenu.csproj" />
<Project Path="src/EnvelopeRenderer.Desktop.Core/EnvelopeRenderer.Desktop.Core.csproj" />
<Project Path="src/EnvelopeRenderer.Desktop.Tests/EnvelopeRenderer.Desktop.Tests.csproj" />
<Project Path="src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj" />
</Folder>
</Solution>

+ 39
- 0
code/README.md Parādīt failu

@@ -9,6 +9,9 @@ This folder is where the actual project codebase lives, built incrementally by t
- `src/EnvelopeRenderer.Cli` — the render CLI. Argument/exit-code contract documented in [`CLI_CONTRACT.md`](CLI_CONTRACT.md); text-only template format in [`TEMPLATE_FORMAT.md`](TEMPLATE_FORMAT.md).
- `src/EnvelopeRenderer.Debenu` — isolates the vendor-generated Debenu interop wrapper (linked, unmodified) in its own project so its lack of nullable annotations doesn't leak warnings elsewhere.
- `src/EnvelopeRenderer.Cli.Tests` — xUnit tests for the CLI (chosen as the test framework in the absence of a project-wide pick; see `project_config.md`), including one integration test that renders through the real Debenu DLL.
- `src/EnvelopeRenderer.Desktop` — the WinForms operator shell. Picks template/CSV/output paths, launches `EnvelopeRenderer.Cli` as a child process against the contract in `CLI_CONTRACT.md`, and shows live progress plus a completion summary while it runs (Sprint 1, Batches 4-5). Contains only Form/UI wiring — see "Running the desktop app" below.
- `src/EnvelopeRenderer.Desktop.Core` — UI-independent logic behind the desktop shell (input validation, CLI argument construction, CLI executable discovery, process launch/lifetime tracking, `PROGRESS` stream parsing, and status/summary formatting). Plain `net10.0`, no WinForms dependency, so it's fully unit testable.
- `src/EnvelopeRenderer.Desktop.Tests` — xUnit tests for `EnvelopeRenderer.Desktop.Core`.

```
dotnet build EnvelopeRenderer.slnx
@@ -22,6 +25,42 @@ DEBENU_LICENSE_KEY="$(cat ../key.txt)" dotnet run --project src/EnvelopeRenderer
--output out.pdf
```

## Running the desktop app

```
dotnet run --project src/EnvelopeRenderer.Desktop
```

`EnvelopeRenderer.Desktop.csproj` references `EnvelopeRenderer.Cli.csproj` as a project reference
purely so a normal `dotnet build`/`dotnet run` always produces a freshly built
`EnvelopeRenderer.Cli.exe` copied next to `EnvelopeRenderer.Desktop.exe` — the desktop app never
calls into the CLI's code directly, it only launches that `.exe` as a child process with
`--template`/`--csv`/`--output` set from the three pickers, per `CLI_CONTRACT.md`. If the CLI
executable has been moved or is deployed separately, point the desktop app at it with the
`ENVELOPERENDERER_CLI_PATH` environment variable (see
[`Launch/CliExecutablePathResolver.cs`](src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResolver.cs)).

The render runs in a hidden child process — the desktop app now reads its `PROGRESS`/`ERROR:`
stdout/stderr streams itself (Sprint 1, Batch 5: "Show render progress and completion summary")
rather than leaving a separate visible console window, per `CLI_CONTRACT.md`. While a render is
running, the status label shows live, non-technical updates (e.g. "128 record(s) processed, 2.1s
elapsed..."); once the process exits, it shows a completion summary — success totals and elapsed
time on exit `0`, or the failure reason (from the CLI's `failure` progress event, falling back to
its stderr `ERROR:` lines for a pre-render validation failure that never reached the render loop)
on any other exit code. The Render button stays disabled for the whole run and is only
re-enabled once the process actually exits, not the moment it starts, so an operator can't launch
a second render against the same output while one is still in flight. A *launch* failure (the CLI
executable itself couldn't be started, e.g. missing or moved) is still reported via an inline
status message and a message box, distinct from a *render* failure.

Note: the CLI/render pipeline does not currently expose a "warning" concept anywhere (e.g. no
missing-image placeholder handling exists yet — that belongs to a not-yet-built epic,
`backlog/epics/07_dynamic_and_network_image_handling.md`, for a template format that doesn't
support images in this text-only slice). The completion summary therefore reports only the
fields the CLI actually exposes today (elapsed time, records completed, success/failure) and
omits a warning count rather than fabricate one — see
[`Launch/RenderCompletionSummaryFormatter.cs`](src/EnvelopeRenderer.Desktop.Core/Launch/RenderCompletionSummaryFormatter.cs).

## Seed assets

- Sample CSV data lives in `code/sample-data/87700 - 999999 - Wilson Township.csv`.


+ 113
- 0
code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs Parādīt failu

@@ -0,0 +1,113 @@
using EnvelopeRenderer.Cli.Progress;

namespace EnvelopeRenderer.Cli.Tests;

public class ConsoleProgressReporterTests
{
private sealed class FakeClock
{
public long NowMs;
public long Read() => NowMs;
}

private static string[] Lines(StringWriter writer) =>
writer.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);

[Fact]
public void Startup_AlwaysWritesImmediately()
{
var writer = new StringWriter();
var clock = new FakeClock { NowMs = 0 };
var reporter = new ConsoleProgressReporter(writer, clock.Read);

reporter.Startup();

var lines = Lines(writer);
var line = Assert.Single(lines);
Assert.Equal("PROGRESS startup elapsedMs=0 completed=0", line);
}

[Fact]
public void ReportRenderProgress_FirstCall_WritesImmediatelyEvenBeforeOneSecondElapses()
{
var writer = new StringWriter();
var clock = new FakeClock { NowMs = 50 };
var reporter = new ConsoleProgressReporter(writer, clock.Read);

reporter.ReportRenderProgress(1);

var line = Assert.Single(Lines(writer));
Assert.Equal("PROGRESS render elapsedMs=50 completed=1", line);
}

[Fact]
public void ReportRenderProgress_SubsequentCallsWithinOneSecond_AreSuppressed()
{
var writer = new StringWriter();
var clock = new FakeClock { NowMs = 0 };
var reporter = new ConsoleProgressReporter(writer, clock.Read);

reporter.ReportRenderProgress(1);
clock.NowMs = 200;
reporter.ReportRenderProgress(2);
clock.NowMs = 999;
reporter.ReportRenderProgress(3);

// Only the first call (elapsedMs=0) should have produced output — the other two are
// both within 1000ms of it, so the "at least once per second, not once per row" rule
// suppresses them.
var line = Assert.Single(Lines(writer));
Assert.Equal("PROGRESS render elapsedMs=0 completed=1", line);
}

[Fact]
public void ReportRenderProgress_AfterOneSecondHasElapsed_WritesAgain()
{
var writer = new StringWriter();
var clock = new FakeClock { NowMs = 0 };
var reporter = new ConsoleProgressReporter(writer, clock.Read);

reporter.ReportRenderProgress(1);
clock.NowMs = 500;
reporter.ReportRenderProgress(2); // suppressed
clock.NowMs = 1000;
reporter.ReportRenderProgress(3); // exactly one second later — should write

var lines = Lines(writer);
Assert.Equal(2, lines.Length);
Assert.Equal("PROGRESS render elapsedMs=0 completed=1", lines[0]);
Assert.Equal("PROGRESS render elapsedMs=1000 completed=3", lines[1]);
}

[Fact]
public void Complete_AlwaysWritesImmediately_RegardlessOfThrottleState()
{
var writer = new StringWriter();
var clock = new FakeClock { NowMs = 0 };
var reporter = new ConsoleProgressReporter(writer, clock.Read);

reporter.ReportRenderProgress(1);
clock.NowMs = 100; // still within the throttle window
reporter.Complete(10);

var lines = Lines(writer);
Assert.Equal(2, lines.Length);
Assert.Equal("PROGRESS complete elapsedMs=100 completed=10", lines[1]);
}

[Fact]
public void Failure_AlwaysWritesImmediately_AndIncludesReason()
{
var writer = new StringWriter();
var clock = new FakeClock { NowMs = 0 };
var reporter = new ConsoleProgressReporter(writer, clock.Read);

reporter.ReportRenderProgress(1);
clock.NowMs = 100; // still within the throttle window
reporter.Failure(1, "Record 2: simulated page failure");

var lines = Lines(writer);
Assert.Equal(2, lines.Length);
Assert.Equal("PROGRESS failure elapsedMs=100 completed=1 reason=Record 2: simulated page failure", lines[1]);
}
}

+ 18
- 1
code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererIntegrationTests.cs Parādīt failu

@@ -1,4 +1,5 @@
using DebenuPDFLibraryDLL1013;
using EnvelopeRenderer.Cli.Progress;
using EnvelopeRenderer.Cli.Render;

namespace EnvelopeRenderer.Cli.Tests;
@@ -13,6 +14,20 @@ namespace EnvelopeRenderer.Cli.Tests;
/// </summary>
public class DebenuPdfRendererIntegrationTests
{
/// <summary>Records every <see cref="IProgressReporter.ReportRenderProgress"/> call
/// unconditionally (no throttling — that behavior belongs to
/// <see cref="EnvelopeRenderer.Cli.Progress.ConsoleProgressReporter"/> and is covered by its
/// own unit tests), so this test can assert RenderEngine calls it once per real record
/// against the actual Debenu render path without depending on wall-clock timing.</summary>
private sealed class RecordingProgressReporter : IProgressReporter
{
public List<int> RenderProgressCalls { get; } = new();
public void Startup() { }
public void ReportRenderProgress(int completed) => RenderProgressCalls.Add(completed);
public void Complete(int completed) { }
public void Failure(int completed, string reason) { }
}

private static string? FindRepoCodeRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
@@ -60,15 +75,17 @@ public class DebenuPdfRendererIntegrationTests
Assert.True(created, createError);

var outputPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.pdf");
var progress = new RecordingProgressReporter();
try
{
using (renderer)
{
var result = RenderEngine.Render(
templateResult.Document!, headers, csv.ReadRecords(), renderer!, outputPath);
templateResult.Document!, headers, csv.ReadRecords(), renderer!, outputPath, progress);

Assert.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(expectedRecordCount, result.RecordsRendered);
Assert.Equal(Enumerable.Range(1, expectedRecordCount), progress.RenderProgressCalls);
}

Assert.True(File.Exists(outputPath));


+ 48
- 0
code/src/EnvelopeRenderer.Cli.Tests/ProgressEventFormatterTests.cs Parādīt failu

@@ -0,0 +1,48 @@
using EnvelopeRenderer.Cli.Progress;

namespace EnvelopeRenderer.Cli.Tests;

public class ProgressEventFormatterTests
{
[Fact]
public void Format_WithoutReason_ProducesFixedFieldOrderLine()
{
var line = ProgressEventFormatter.Format("startup", 0, 0);

Assert.Equal("PROGRESS startup elapsedMs=0 completed=0", line);
}

[Fact]
public void Format_ForRenderEvent_IncludesElapsedAndCompletedCounts()
{
var line = ProgressEventFormatter.Format("render", 1234, 42);

Assert.Equal("PROGRESS render elapsedMs=1234 completed=42", line);
}

[Fact]
public void Format_WithReason_AppendsReasonAsFinalField()
{
var line = ProgressEventFormatter.Format("failure", 500, 3, "Record 4: simulated page failure");

Assert.Equal("PROGRESS failure elapsedMs=500 completed=3 reason=Record 4: simulated page failure", line);
}

[Fact]
public void Format_ReasonContainingNewlines_SanitizesToASingleLine()
{
var line = ProgressEventFormatter.Format("failure", 0, 0, "first line\nsecond line\r\nthird line");

Assert.DoesNotContain('\n', line);
Assert.DoesNotContain('\r', line);
Assert.Equal("PROGRESS failure elapsedMs=0 completed=0 reason=first line second line third line", line);
}

[Fact]
public void Format_StartsWithFixedPrefixAndKind_SoConsumersCanFilterEasily()
{
var line = ProgressEventFormatter.Format("complete", 99, 5);

Assert.StartsWith("PROGRESS complete ", line);
}
}

+ 58
- 0
code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs Parādīt failu

@@ -1,9 +1,23 @@
using EnvelopeRenderer.Cli.Progress;
using EnvelopeRenderer.Cli.Render;

namespace EnvelopeRenderer.Cli.Tests;

public class RenderEngineTests
{
private sealed class SpyProgressReporter : IProgressReporter
{
public List<int> RenderProgressCalls { get; } = new();
public bool StartupCalled { get; private set; }
public int? CompletedCount { get; private set; }
public (int Completed, string Reason)? FailureCall { get; private set; }

public void Startup() => StartupCalled = true;
public void ReportRenderProgress(int completed) => RenderProgressCalls.Add(completed);
public void Complete(int completed) => CompletedCount = completed;
public void Failure(int completed, string reason) => FailureCall = (completed, reason);
}

private sealed class FakePdfRenderer : IPdfRenderer
{
public List<(double Width, double Height, IReadOnlyList<TextDraw> Draws)> Pages { get; } = new();
@@ -129,4 +143,48 @@ public class RenderEngineTests

Assert.True(result.Succeeded);
}

[Fact]
public void Render_ReportsProgressAfterEveryRecord_WithRunningTotal()
{
var renderer = new FakePdfRenderer();
var progress = new SpyProgressReporter();

var result = RenderEngine.Render(Template, new[] { "Full Name" }, Records, renderer, "out.pdf", progress);

Assert.True(result.Succeeded);
Assert.Equal(new[] { 1, 2 }, progress.RenderProgressCalls);
}

[Fact]
public void Render_WithoutProgressReporter_StillSucceeds()
{
var renderer = new FakePdfRenderer();

var result = RenderEngine.Render(Template, new[] { "Full Name" }, Records, renderer, "out.pdf");

Assert.True(result.Succeeded);
}

[Fact]
public void Render_UnknownColumnReference_NeverReportsProgress()
{
var renderer = new FakePdfRenderer();
var progress = new SpyProgressReporter();

RenderEngine.Render(Template, new[] { "Some Other Column" }, Records, renderer, "out.pdf", progress);

Assert.Empty(progress.RenderProgressCalls);
}

[Fact]
public void Render_PageFailure_OnlyReportsProgressForRecordsThatSucceededBeforeTheFailure()
{
var renderer = new FakePdfRenderer { FailOnPage = true };
var progress = new SpyProgressReporter();

RenderEngine.Render(Template, new[] { "Full Name" }, Records, renderer, "out.pdf", progress);

Assert.Empty(progress.RenderProgressCalls);
}
}

+ 13
- 2
code/src/EnvelopeRenderer.Cli/Program.cs Parādīt failu

@@ -1,4 +1,5 @@
using EnvelopeRenderer.Cli.Cli;
using EnvelopeRenderer.Cli.Progress;
using EnvelopeRenderer.Cli.Render;

const string Usage = """
@@ -48,10 +49,14 @@ switch (result.Kind)

static int RunRender(CliArguments args)
{
var progress = ConsoleProgressReporter.CreateDefault(Console.Out);
progress.Startup();

var templateResult = TemplateXmlParser.Parse(args.TemplatePath);
if (!templateResult.Succeeded)
{
WriteErrors(templateResult.Errors);
progress.Failure(0, string.Join("; ", templateResult.Errors));
return ExitCodes.UnexpectedError;
}

@@ -63,7 +68,9 @@ static int RunRender(CliArguments args)
}
catch (Exception ex)
{
Console.Error.WriteLine($"ERROR: Failed to read CSV headers from '{args.CsvPath}': {ex.Message}");
var message = $"Failed to read CSV headers from '{args.CsvPath}': {ex.Message}";
Console.Error.WriteLine($"ERROR: {message}");
progress.Failure(0, message);
return ExitCodes.UnexpectedError;
}

@@ -75,19 +82,23 @@ static int RunRender(CliArguments args)
if (!DebenuPdfRenderer.TryCreate(dllPath, licenseKey, out var renderer, out var createError))
{
Console.Error.WriteLine($"ERROR: {createError}");
progress.Failure(0, createError!);
return ExitCodes.UnexpectedError;
}

using (renderer)
{
var renderResult = RenderEngine.Render(
templateResult.Document!, headers, csv.ReadRecords(), renderer!, args.OutputPath);
templateResult.Document!, headers, csv.ReadRecords(), renderer!, args.OutputPath, progress);

if (!renderResult.Succeeded)
{
WriteErrors(renderResult.Errors);
progress.Failure(renderResult.RecordsRendered, string.Join("; ", renderResult.Errors));
return ExitCodes.UnexpectedError;
}

progress.Complete(renderResult.RecordsRendered);
}

return ExitCodes.Success;


+ 62
- 0
code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs Parādīt failu

@@ -0,0 +1,62 @@
using System.Diagnostics;

namespace EnvelopeRenderer.Cli.Progress;

/// <summary>
/// Writes PROGRESS lines to the given <see cref="TextWriter"/> (stdout in production). Only the
/// high-frequency `render` event is throttled — to at least once per second, per the "Emit
/// machine-readable progress during render" story — because `startup`, `complete`, and `failure`
/// each happen exactly once per run and must never be dropped. The elapsed-time source is
/// injected (rather than reading <see cref="Stopwatch"/> directly) so tests can drive throttling
/// deterministically without real `Thread.Sleep` calls; <see cref="CreateDefault"/> wires up a
/// real stopwatch for production use.
/// </summary>
public sealed class ConsoleProgressReporter : IProgressReporter
{
private readonly TextWriter _output;
private readonly Func<long> _elapsedMillisecondsProvider;
private readonly long _minIntervalMs;
private long? _lastEmittedAtMs;

public ConsoleProgressReporter(
TextWriter output, Func<long> elapsedMillisecondsProvider, long minIntervalMs = 1000)
{
_output = output;
_elapsedMillisecondsProvider = elapsedMillisecondsProvider;
_minIntervalMs = minIntervalMs;
}

/// <summary>Production factory: throttles against a real, freshly-started stopwatch.</summary>
public static ConsoleProgressReporter CreateDefault(TextWriter output)
{
var stopwatch = Stopwatch.StartNew();
return new ConsoleProgressReporter(output, () => stopwatch.ElapsedMilliseconds);
}

public void Startup() => Write("startup", 0);

public void ReportRenderProgress(int completed)
{
var elapsed = _elapsedMillisecondsProvider();

// Always emit the first call immediately (so a slow run shows *something* right away),
// then suppress further calls until at least _minIntervalMs has passed since the last
// one actually written — this is the "at least once per second, not once per row" rule.
if (_lastEmittedAtMs is not null && elapsed - _lastEmittedAtMs.Value < _minIntervalMs)
{
return;
}

_lastEmittedAtMs = elapsed;
Write("render", completed);
}

public void Complete(int completed) => Write("complete", completed);

public void Failure(int completed, string reason) => Write("failure", completed, reason);

private void Write(string kind, int completed, string? reason = null)
{
_output.WriteLine(ProgressEventFormatter.Format(kind, _elapsedMillisecondsProvider(), completed, reason));
}
}

+ 29
- 0
code/src/EnvelopeRenderer.Cli/Progress/IProgressReporter.cs Parādīt failu

@@ -0,0 +1,29 @@
namespace EnvelopeRenderer.Cli.Progress;

/// <summary>
/// Emits the CLI's line-oriented progress events on stdout — see the "stdout / stderr rules" and
/// "Progress events" sections of CLI_CONTRACT.md for the wire format. Kept as an interface so
/// <see cref="EnvelopeRenderer.Cli.Render.RenderEngine"/>'s merge loop (and its unit tests) don't
/// depend on real console I/O or real wall-clock timing.
/// </summary>
public interface IProgressReporter
{
/// <summary>Emitted exactly once, right before rendering begins (template parsing, CSV
/// header validation, and Debenu setup all happen after this).</summary>
void Startup();

/// <summary>Called after every successfully rendered record with the running total. An
/// implementation is free to not actually write a line for every call — see
/// <see cref="ConsoleProgressReporter"/>'s throttling — but every call must be safe and
/// cheap since <c>RenderEngine</c> calls it once per CSV row.</summary>
void ReportRenderProgress(int completed);

/// <summary>Emitted exactly once, on a successful run, and always written immediately
/// (never throttled/dropped).</summary>
void Complete(int completed);

/// <summary>Emitted exactly once, on a failed run, and always written immediately. This
/// supplements — never replaces — the corresponding `ERROR: `-prefixed stderr line(s); the
/// exit code and stderr contract are unchanged by this event.</summary>
void Failure(int completed, string reason);
}

+ 29
- 0
code/src/EnvelopeRenderer.Cli/Progress/ProgressEventFormatter.cs Parādīt failu

@@ -0,0 +1,29 @@
namespace EnvelopeRenderer.Cli.Progress;

/// <summary>
/// Formats a single progress event as one line of plain `PROGRESS &lt;kind&gt; key=value ...`
/// text. Chosen over a JSON-object-per-line format for three reasons: (1) it stays trivially
/// parseable with a `Split(' ')` — no JSON library dependency needed in the lightweight desktop
/// shell that will consume it in a later story; (2) it stays readable at a terminal or in a log
/// file when the CLI is run manually, matching the plain-text style already used for stderr's
/// `ERROR: ` lines; (3) every numeric field is a culture-invariant integer, so there's no
/// decimal-separator ambiguity to worry about across locales. The one field that can contain
/// arbitrary text, `reason`, is always last and takes the rest of the line verbatim (newlines
/// stripped) so it never needs quoting or escaping.
/// </summary>
public static class ProgressEventFormatter
{
public const string Prefix = "PROGRESS";

public static string Format(string kind, long elapsedMs, int completed, string? reason = null)
{
var line = $"{Prefix} {kind} elapsedMs={elapsedMs} completed={completed}";
if (reason is not null)
{
var sanitized = reason.Replace('\r', ' ').Replace('\n', ' ');
line += $" reason={sanitized}";
}

return line;
}
}

+ 5
- 1
code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs Parādīt failu

@@ -1,3 +1,5 @@
using EnvelopeRenderer.Cli.Progress;

namespace EnvelopeRenderer.Cli.Render;

/// <summary>
@@ -12,7 +14,8 @@ public static class RenderEngine
IReadOnlyList<string> csvHeaders,
IEnumerable<IReadOnlyDictionary<string, string>> records,
IPdfRenderer renderer,
string outputPath)
string outputPath,
IProgressReporter? progress = null)
{
var unknownColumns = template.Elements
.Where(e => e.IsDynamic)
@@ -47,6 +50,7 @@ public static class RenderEngine
}

recordCount++;
progress?.ReportRenderProgress(recordCount);
}

if (recordCount == 0)


+ 17
- 0
code/src/EnvelopeRenderer.Desktop.Core/EnvelopeRenderer.Desktop.Core.csproj Parādīt failu

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
UI-independent logic for the desktop shell (Sprint 1, Batch 4: "Launch a text-only render
from the desktop app"): input validation, CLI argument construction, CLI executable
discovery, and process launch. Deliberately targets plain net10.0 (not net10.0-windows) so
it carries no WinForms dependency and can be unit tested from a normal xUnit test project —
see EnvelopeRenderer.Desktop.Tests. EnvelopeRenderer.Desktop (the WinForms shell) references
this project and contains only the Form/UI wiring.
-->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>

+ 26
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/CliArgumentListBuilder.cs Parādīt failu

@@ -0,0 +1,26 @@
namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>
/// Builds the CLI argument list from validated <see cref="RenderLaunchInputs"/>, matching the
/// `--template &lt;path&gt; --csv &lt;path&gt; --output &lt;path&gt;` contract in
/// ../../../CLI_CONTRACT.md exactly (argument names, order, and "no `=` form" are the CLI's
/// contract to change, not ours to reinterpret). Values are returned as separate list entries
/// (destined for <see cref="System.Diagnostics.ProcessStartInfo.ArgumentList"/>) rather than a
/// single pre-quoted string, so paths containing spaces need no manual quoting/escaping here.
/// </summary>
public static class CliArgumentListBuilder
{
/// <summary>
/// Builds the argument list. Callers must run <see cref="RenderLaunchValidator"/> first —
/// this does not re-check for blank values.
/// </summary>
public static IReadOnlyList<string> Build(RenderLaunchInputs inputs)
{
return new[]
{
"--template", inputs.TemplatePath!,
"--csv", inputs.CsvPath!,
"--output", inputs.OutputPath!,
};
}
}

+ 49
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResolver.cs Parādīt failu

@@ -0,0 +1,49 @@
namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>
/// Locates the EnvelopeRenderer.Cli executable that the desktop shell launches as a child
/// process. This is a desktop-app implementation detail, not part of CLI_CONTRACT.md.
///
/// Resolution order:
/// 1. The <see cref="EnvironmentVariableOverride"/> environment variable, if set — lets an
/// operator/installer point at a specific build without rebuilding the desktop app.
/// 2. <see cref="ExecutableFileName"/> next to the desktop app's own executable. In local dev,
/// EnvelopeRenderer.Desktop.csproj references EnvelopeRenderer.Cli.csproj as a project
/// reference specifically so `dotnet build`/`dotnet run` always place a freshly built CLI
/// exe there; in a packaged install, the two would be deployed side by side the same way.
/// </summary>
public static class CliExecutablePathResolver
{
public const string EnvironmentVariableOverride = "ENVELOPERENDERER_CLI_PATH";
public const string ExecutableFileName = "EnvelopeRenderer.Cli.exe";

public static CliExecutablePathResult Resolve(string appBaseDirectory) =>
Resolve(appBaseDirectory, Environment.GetEnvironmentVariable, File.Exists);

public static CliExecutablePathResult Resolve(
string appBaseDirectory,
Func<string, string?> getEnvironmentVariable,
Func<string, bool> fileExists)
{
var overridePath = getEnvironmentVariable(EnvironmentVariableOverride);
if (!string.IsNullOrWhiteSpace(overridePath))
{
return fileExists(overridePath)
? CliExecutablePathResult.Found(overridePath)
: CliExecutablePathResult.NotFound(
$"The {EnvironmentVariableOverride} environment variable points to a file " +
$"that does not exist: '{overridePath}'.");
}

var sibling = Path.Combine(appBaseDirectory, ExecutableFileName);
if (fileExists(sibling))
{
return CliExecutablePathResult.Found(sibling);
}

return CliExecutablePathResult.NotFound(
$"Could not find the render program ('{ExecutableFileName}'). It is expected next " +
$"to the Envelope Renderer application. If it has been moved, set the " +
$"{EnvironmentVariableOverride} environment variable to its full path and restart.");
}
}

+ 9
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResult.cs Parādīt failu

@@ -0,0 +1,9 @@
namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>Result of trying to locate the EnvelopeRenderer.Cli executable to launch.</summary>
public sealed record CliExecutablePathResult(bool IsFound, string? Path, string? Error)
{
public static CliExecutablePathResult Found(string path) => new(true, path, null);

public static CliExecutablePathResult NotFound(string error) => new(false, null, error);
}

+ 106
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessLauncher.cs Parādīt failu

@@ -0,0 +1,106 @@
using System.ComponentModel;
using System.Diagnostics;

namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>
/// Starts the CLI as a child process, streams its stdout/stderr while it runs, and reports the
/// full outcome once it exits — all without blocking the calling (UI) thread. Sprint 1, Batch 4
/// only proved the process could be *started*; this class (Batch 5, "Show render progress and
/// completion summary") is what actually reads the `PROGRESS` stream documented in
/// ../../../CLI_CONTRACT.md and tracks the process through to its exit code. That tracking is
/// also what lets the caller (see EnvelopeRenderer.Desktop's MainForm) hold the Render button
/// disabled for the whole run instead of re-enabling it the instant the process starts, closing
/// the double-launch gap logged in logs/technical_debt_log.md.
/// </summary>
public sealed class CliProcessLauncher
{
private readonly Func<ProcessStartInfo, ICliProcess> _startProcess;

/// <param name="startProcess">
/// Test seam: given a <see cref="ProcessStartInfo"/>, starts a process and returns a handle
/// to it. Defaults to actually starting a real <see cref="Process"/>.
/// </param>
public CliProcessLauncher(Func<ProcessStartInfo, ICliProcess>? startProcess = null)
{
_startProcess = startProcess ?? DefaultStartProcess;
}

private static ICliProcess DefaultStartProcess(ProcessStartInfo startInfo)
{
var process = new Process { StartInfo = startInfo };
process.Start();
return new RealCliProcess(process);
}

/// <summary>
/// Launches the CLI and runs it to completion on a background thread so the UI thread never
/// blocks on it. Each successfully parsed progress event is reported via
/// <paramref name="onProgress"/> as it arrives — construct it as a <see cref="Progress{T}"/>
/// on the UI thread so updates are automatically marshalled back to it.
/// </summary>
public Task<RenderRunResult> RunAsync(
string executablePath, IReadOnlyList<string> arguments, IProgress<ProgressEvent>? onProgress = null) =>
Task.Run(() => Run(executablePath, arguments, onProgress));

/// <summary>Synchronous run, exposed directly for unit testing without threading noise.</summary>
public RenderRunResult Run(
string executablePath, IReadOnlyList<string> arguments, IProgress<ProgressEvent>? onProgress = null)
{
var startInfo = CliProcessStartInfoFactory.Create(executablePath, arguments);

ICliProcess process;
try
{
process = _startProcess(startInfo);
}
catch (Win32Exception ex)
{
return RenderRunResult.StartFailure(
$"The render program could not be started. It may be missing, moved, or blocked. ({ex.Message})");
}
catch (Exception ex)
{
return RenderRunResult.StartFailure($"The render program could not be started. ({ex.Message})");
}

using (process)
{
ProgressEvent? finalProgress = null;
var stdErrLines = new List<string>();

// Kick off both readers before blocking on exit, so a full stdout/stderr pipe never
// makes the child process wait on us while we're still waiting on it.
var stdOutTask = ReadLinesAsync(process.StandardOutput, line =>
{
if (ProgressEventParser.TryParse(line, out var parsed) && parsed is not null)
{
finalProgress = parsed;
onProgress?.Report(parsed);
}
});

var stdErrTask = ReadLinesAsync(process.StandardError, line =>
{
if (!string.IsNullOrEmpty(line))
{
stdErrLines.Add(line);
}
});

var exitCode = process.WaitForExitAsync().GetAwaiter().GetResult();
Task.WaitAll(stdOutTask, stdErrTask);

return new RenderRunResult(true, null, exitCode, finalProgress, stdErrLines);
}
}

private static async Task ReadLinesAsync(TextReader reader, Action<string> onLine)
{
string? line;
while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) is not null)
{
onLine(line);
}
}
}

+ 32
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessStartInfoFactory.cs Parādīt failu

@@ -0,0 +1,32 @@
using System.Diagnostics;

namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>
/// Builds the <see cref="ProcessStartInfo"/> used to launch the CLI as a child process.
/// Redirects stdout/stderr and hides the console window: Sprint 1, Batch 5
/// ("Show render progress and completion summary") reads the CLI's `PROGRESS`/`ERROR:` streams
/// itself (see <see cref="CliProcessLauncher"/>) and displays them inside the desktop shell, so a
/// separate visible console window is no longer needed for the operator to see progress.
/// </summary>
public static class CliProcessStartInfoFactory
{
public static ProcessStartInfo Create(string executablePath, IReadOnlyList<string> arguments)
{
var startInfo = new ProcessStartInfo
{
FileName = executablePath,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};

foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}

return startInfo;
}
}

+ 10
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/ElapsedTimeFormatter.cs Parādīt failu

@@ -0,0 +1,10 @@
using System.Globalization;

namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>Formats a millisecond duration from a `PROGRESS` event as short, human-friendly text.</summary>
public static class ElapsedTimeFormatter
{
public static string Format(int elapsedMs) =>
(elapsedMs / 1000.0).ToString("0.0", CultureInfo.InvariantCulture) + "s";
}

+ 17
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/ICliProcess.cs Parādīt failu

@@ -0,0 +1,17 @@
namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>
/// Minimal abstraction over a started child process: just enough for
/// <see cref="CliProcessLauncher"/> to stream its stdout/stderr and learn its exit code, without
/// tying <see cref="CliProcessLauncher"/>'s tests to a real OS process. Production code gets
/// <see cref="RealCliProcess"/>; tests inject an in-memory fake backed by <see cref="StringReader"/>s.
/// </summary>
public interface ICliProcess : IDisposable
{
TextReader StandardOutput { get; }

TextReader StandardError { get; }

/// <summary>Waits for the process to exit and returns its exit code.</summary>
Task<int> WaitForExitAsync();
}

+ 8
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEvent.cs Parādīt failu

@@ -0,0 +1,8 @@
namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>
/// A single parsed `PROGRESS &lt;kind&gt; elapsedMs=&lt;n&gt; completed=&lt;n&gt; [reason=&lt;text&gt;]`
/// line from the CLI's stdout, per ../../../CLI_CONTRACT.md. <see cref="Reason"/> is only ever
/// populated for <see cref="ProgressEventKind.Failure"/>.
/// </summary>
public sealed record ProgressEvent(ProgressEventKind Kind, int ElapsedMs, int Completed, string? Reason);

+ 12
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventKind.cs Parādīt failu

@@ -0,0 +1,12 @@
namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>
/// Mirrors the `PROGRESS &lt;kind&gt; ...` event kinds documented in ../../../CLI_CONTRACT.md.
/// </summary>
public enum ProgressEventKind
{
Startup,
Render,
Complete,
Failure,
}

+ 99
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventParser.cs Parādīt failu

@@ -0,0 +1,99 @@
using System.Globalization;

namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>
/// Parses one line of the CLI's stdout progress stream, per the fixed
/// `PROGRESS &lt;kind&gt; elapsedMs=&lt;n&gt; completed=&lt;n&gt; [reason=&lt;text&gt;]` format
/// documented in ../../../CLI_CONTRACT.md. Matches the contract's own stated parsing guidance:
/// split on spaces into at most 5 fields so `reason` (always last, taking the rest of the line
/// verbatim) never needs quoting or escaping. Lines that don't match this shape (blank lines,
/// or anything else that might ever appear on the CLI's stdout) simply aren't progress events —
/// the caller decides what, if anything, to do with them; this parser never throws on bad input.
/// </summary>
public static class ProgressEventParser
{
private const string Prefix = "PROGRESS";
private const int MaxFields = 5;

public static bool TryParse(string? line, out ProgressEvent? progressEvent)
{
progressEvent = null;

if (string.IsNullOrWhiteSpace(line))
{
return false;
}

var fields = line.Split(' ', MaxFields);
if (fields.Length < 4 || fields[0] != Prefix)
{
return false;
}

if (!TryParseKind(fields[1], out var kind))
{
return false;
}

if (!TryParseKeyValue(fields[2], "elapsedMs", out var elapsedMs))
{
return false;
}

if (!TryParseKeyValue(fields[3], "completed", out var completed))
{
return false;
}

string? reason = null;
if (fields.Length == 5)
{
const string reasonPrefix = "reason=";
if (!fields[4].StartsWith(reasonPrefix, StringComparison.Ordinal))
{
return false;
}

reason = fields[4][reasonPrefix.Length..];
}

progressEvent = new ProgressEvent(kind, elapsedMs, completed, reason);
return true;
}

private static bool TryParseKind(string text, out ProgressEventKind kind)
{
switch (text)
{
case "startup":
kind = ProgressEventKind.Startup;
return true;
case "render":
kind = ProgressEventKind.Render;
return true;
case "complete":
kind = ProgressEventKind.Complete;
return true;
case "failure":
kind = ProgressEventKind.Failure;
return true;
default:
kind = default;
return false;
}
}

private static bool TryParseKeyValue(string field, string key, out int value)
{
value = 0;
var prefix = key + "=";
if (!field.StartsWith(prefix, StringComparison.Ordinal))
{
return false;
}

return int.TryParse(
field.AsSpan(prefix.Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
}
}

+ 26
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/RealCliProcess.cs Parādīt failu

@@ -0,0 +1,26 @@
using System.Diagnostics;

namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>Wraps a real, already-started <see cref="Process"/> as an <see cref="ICliProcess"/>.</summary>
internal sealed class RealCliProcess : ICliProcess
{
private readonly Process _process;

public RealCliProcess(Process process)
{
_process = process;
}

public TextReader StandardOutput => _process.StandardOutput;

public TextReader StandardError => _process.StandardError;

public async Task<int> WaitForExitAsync()
{
await _process.WaitForExitAsync().ConfigureAwait(false);
return _process.ExitCode;
}

public void Dispose() => _process.Dispose();
}

+ 55
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderCompletionSummaryFormatter.cs Parādīt failu

@@ -0,0 +1,55 @@
namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>
/// Formats the final outcome of a render run (see <see cref="RenderRunResult"/>) as a
/// non-technical completion summary for a print operator. Covers three cases: the process never
/// started at all (a launch failure — the message is already friendly and passed through
/// verbatim), the process started and succeeded (exit 0; uses the `complete` event's
/// totals/elapsed time), and the process started but failed (non-zero exit; prefers the
/// `failure` event's `reason` field, and falls back to the stderr `ERROR: ` lines for the rarer
/// case where the process exited before emitting any progress event at all — e.g. a template or
/// CSV path that stopped existing between being picked and clicking Render, which the CLI
/// reports as a pre-render validation failure with no progress events per CLI_CONTRACT.md).
///
/// Ambiguity resolved during Sprint 1, Batch 5: this story's acceptance task asked the summary to
/// include a "warning count," but neither `PROGRESS` events nor the render pipeline currently
/// expose any warning concept (searched CLI_CONTRACT.md and the CLI/render source directly). The
/// only candidate — missing-image placeholder handling referenced in
/// templates/definition_of_done.md — belongs to a not-yet-built epic
/// (backlog/epics/07_dynamic_and_network_image_handling.md) for a template format that doesn't
/// support images yet in this text-only slice. Rather than fabricate a counter, this formatter
/// reports only the fields the CLI actually exposes today (elapsed time, records completed,
/// success/failure) and omits a warning count; see the Batch 5 hand-off notes for the flagged gap.
/// </summary>
public static class RenderCompletionSummaryFormatter
{
public static string Format(RenderRunResult result)
{
if (!result.Started)
{
return result.StartFailureMessage!;
}

if (result.Succeeded)
{
var completed = result.FinalProgress?.Completed ?? 0;
return result.FinalProgress is not null
? $"Render complete: {completed} record(s) rendered in {ElapsedTimeFormatter.Format(result.FinalProgress.ElapsedMs)}."
: $"Render complete: {completed} record(s) rendered.";
}

if (result.FinalProgress is { Kind: ProgressEventKind.Failure } failure)
{
var elapsed = ElapsedTimeFormatter.Format(failure.ElapsedMs);
var reason = string.IsNullOrWhiteSpace(failure.Reason) ? "Unknown error." : failure.Reason;
return $"Render failed after {elapsed} ({failure.Completed} record(s) rendered): {reason}";
}

if (result.StdErrLines.Count > 0)
{
return $"Render failed before starting: {string.Join(" ", result.StdErrLines)}";
}

return $"Render failed (exit code {result.ExitCode}). See the render program's output for details.";
}
}

+ 8
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchInputs.cs Parādīt failu

@@ -0,0 +1,8 @@
namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>
/// The three operator-entered paths needed to launch a render, exactly as typed/picked in the
/// UI and before any validation. All three map 1:1 to the CLI's required arguments documented
/// in ../../../CLI_CONTRACT.md (--template, --csv, --output).
/// </summary>
public sealed record RenderLaunchInputs(string? TemplatePath, string? CsvPath, string? OutputPath);

+ 9
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidationResult.cs Parādīt failu

@@ -0,0 +1,9 @@
namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>Result of validating <see cref="RenderLaunchInputs"/> before allowing a launch.</summary>
public sealed record RenderLaunchValidationResult(bool IsValid, IReadOnlyList<string> Errors)
{
public static readonly RenderLaunchValidationResult Valid = new(true, Array.Empty<string>());

public static RenderLaunchValidationResult Invalid(IReadOnlyList<string> errors) => new(false, errors);
}

+ 35
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidator.cs Parādīt failu

@@ -0,0 +1,35 @@
namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>
/// Basic, non-technical "did the operator fill in everything" validation performed by the
/// desktop shell before it launches the CLI. This intentionally does NOT duplicate the CLI's
/// own path-existence/type validation (missing template/CSV, non-existent output directory,
/// etc. — exit codes 2-4 in CLI_CONTRACT.md); the CLI already validates and reports those, and
/// this batch does not parse the CLI's output. This only catches the case where a required
/// field is blank so an operator gets an immediate, friendly message instead of watching the
/// CLI fail moments later for a reason it can't yet see.
/// </summary>
public static class RenderLaunchValidator
{
public static RenderLaunchValidationResult Validate(RenderLaunchInputs inputs)
{
var errors = new List<string>();

if (string.IsNullOrWhiteSpace(inputs.TemplatePath))
{
errors.Add("Please choose a template file before rendering.");
}

if (string.IsNullOrWhiteSpace(inputs.CsvPath))
{
errors.Add("Please choose a CSV data file before rendering.");
}

if (string.IsNullOrWhiteSpace(inputs.OutputPath))
{
errors.Add("Please choose where to save the rendered PDF before rendering.");
}

return errors.Count == 0 ? RenderLaunchValidationResult.Valid : RenderLaunchValidationResult.Invalid(errors);
}
}

+ 23
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderProgressStatusFormatter.cs Parādīt failu

@@ -0,0 +1,23 @@
namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>
/// Formats a single in-flight `PROGRESS` event (see ../../../CLI_CONTRACT.md) as a short,
/// non-technical status line for a print operator watching a render in progress. Intended to be
/// called for every event delivered via <see cref="CliProcessLauncher.RunAsync"/>'s progress
/// callback, including the terminal `complete`/`failure` events as they stream by — the final
/// completion summary (see <see cref="RenderCompletionSummaryFormatter"/>) is a separate, richer
/// message shown once the process has actually exited, since only then do we also know the exit
/// code and have the full stderr text available as a fallback.
/// </summary>
public static class RenderProgressStatusFormatter
{
public static string Format(ProgressEvent progressEvent) => progressEvent.Kind switch
{
ProgressEventKind.Startup => "Starting render...",
ProgressEventKind.Render =>
$"{progressEvent.Completed} record(s) processed, {ElapsedTimeFormatter.Format(progressEvent.ElapsedMs)} elapsed...",
ProgressEventKind.Complete => $"Finishing up... {progressEvent.Completed} record(s) rendered.",
ProgressEventKind.Failure => "Render failed. Finishing up...",
_ => string.Empty,
};
}

+ 26
- 0
code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderRunResult.cs Parādīt failu

@@ -0,0 +1,26 @@
namespace EnvelopeRenderer.Desktop.Core.Launch;

/// <summary>
/// The full outcome of launching the CLI and running it to completion. Distinguishes a *launch*
/// failure (<see cref="Started"/> is <c>false</c> — the process never started at all, see
/// <see cref="StartFailureMessage"/>) from a *render* outcome (the process started, ran, and
/// exited — see <see cref="ExitCode"/> and <see cref="FinalProgress"/>, the last successfully
/// parsed `PROGRESS` line). Per ../../../CLI_CONTRACT.md, <see cref="FinalProgress"/> is always
/// the run's `complete` or `failure` event, unless the process exited before emitting any
/// progress event at all (e.g. an input became invalid between picking it and clicking Render,
/// producing one of the CLI's pre-render validation failures) — in that case it is <c>null</c>
/// and <see cref="StdErrLines"/> is the only source of detail.
/// </summary>
public sealed record RenderRunResult(
bool Started,
string? StartFailureMessage,
int? ExitCode,
ProgressEvent? FinalProgress,
IReadOnlyList<string> StdErrLines)
{
/// <summary>True only when the process started and exited with code 0.</summary>
public bool Succeeded => Started && ExitCode == 0;

public static RenderRunResult StartFailure(string message) =>
new(Started: false, StartFailureMessage: message, ExitCode: null, FinalProgress: null, StdErrLines: Array.Empty<string>());
}

+ 34
- 0
code/src/EnvelopeRenderer.Desktop.Tests/CliArgumentListBuilderTests.cs Parādīt failu

@@ -0,0 +1,34 @@
using EnvelopeRenderer.Desktop.Core.Launch;

namespace EnvelopeRenderer.Desktop.Tests;

public class CliArgumentListBuilderTests
{
[Fact]
public void Build_ReturnsFlagsAndValuesInContractOrder()
{
var inputs = new RenderLaunchInputs("template.xml", "data.csv", "out.pdf");

var arguments = CliArgumentListBuilder.Build(inputs);

Assert.Equal(new[] { "--template", "template.xml", "--csv", "data.csv", "--output", "out.pdf" }, arguments);
}

[Fact]
public void Build_PathsWithSpaces_AreNotQuotedByTheBuilder()
{
// ProcessStartInfo.ArgumentList (see CliProcessStartInfoFactory) handles quoting for us;
// the builder must hand back raw values, not pre-quoted strings, or paths would end up
// double-quoted on the actual command line.
var inputs = new RenderLaunchInputs(
"C:\\Templates\\envelope template.xml",
"C:\\Data\\wilson township.csv",
"C:\\Out\\my report.pdf");

var arguments = CliArgumentListBuilder.Build(inputs);

Assert.Equal("C:\\Templates\\envelope template.xml", arguments[1]);
Assert.Equal("C:\\Data\\wilson township.csv", arguments[3]);
Assert.Equal("C:\\Out\\my report.pdf", arguments[5]);
}
}

+ 74
- 0
code/src/EnvelopeRenderer.Desktop.Tests/CliExecutablePathResolverTests.cs Parādīt failu

@@ -0,0 +1,74 @@
using EnvelopeRenderer.Desktop.Core.Launch;

namespace EnvelopeRenderer.Desktop.Tests;

public class CliExecutablePathResolverTests
{
[Fact]
public void Resolve_EnvironmentOverrideSetAndExists_UsesOverride()
{
var result = CliExecutablePathResolver.Resolve(
appBaseDirectory: @"C:\App",
getEnvironmentVariable: _ => @"D:\Custom\EnvelopeRenderer.Cli.exe",
fileExists: path => path == @"D:\Custom\EnvelopeRenderer.Cli.exe");

Assert.True(result.IsFound);
Assert.Equal(@"D:\Custom\EnvelopeRenderer.Cli.exe", result.Path);
}

[Fact]
public void Resolve_EnvironmentOverrideSetButMissing_FailsWithClearMessageNamingTheVariable()
{
var result = CliExecutablePathResolver.Resolve(
appBaseDirectory: @"C:\App",
getEnvironmentVariable: _ => @"D:\Custom\EnvelopeRenderer.Cli.exe",
fileExists: _ => false);

Assert.False(result.IsFound);
Assert.Contains(CliExecutablePathResolver.EnvironmentVariableOverride, result.Error);
Assert.Contains(@"D:\Custom\EnvelopeRenderer.Cli.exe", result.Error);
}

[Fact]
public void Resolve_NoOverride_FallsBackToSiblingOfAppBaseDirectory()
{
var expectedPath = Path.Combine(@"C:\App", CliExecutablePathResolver.ExecutableFileName);

var result = CliExecutablePathResolver.Resolve(
appBaseDirectory: @"C:\App",
getEnvironmentVariable: _ => null,
fileExists: path => path == expectedPath);

Assert.True(result.IsFound);
Assert.Equal(expectedPath, result.Path);
}

[Fact]
public void Resolve_NoOverrideAndNoSibling_FailsWithHelpfulMessage()
{
var result = CliExecutablePathResolver.Resolve(
appBaseDirectory: @"C:\App",
getEnvironmentVariable: _ => null,
fileExists: _ => false);

Assert.False(result.IsFound);
Assert.Contains(CliExecutablePathResolver.ExecutableFileName, result.Error);
Assert.Contains(CliExecutablePathResolver.EnvironmentVariableOverride, result.Error);
}

[Theory]
[InlineData("")]
[InlineData(" ")]
public void Resolve_BlankEnvironmentOverride_IsTreatedAsNotSet(string blankOverride)
{
var expectedPath = Path.Combine(@"C:\App", CliExecutablePathResolver.ExecutableFileName);

var result = CliExecutablePathResolver.Resolve(
appBaseDirectory: @"C:\App",
getEnvironmentVariable: _ => blankOverride,
fileExists: path => path == expectedPath);

Assert.True(result.IsFound);
Assert.Equal(expectedPath, result.Path);
}
}

+ 242
- 0
code/src/EnvelopeRenderer.Desktop.Tests/CliProcessLauncherTests.cs Parādīt failu

@@ -0,0 +1,242 @@
using System.ComponentModel;
using EnvelopeRenderer.Desktop.Core.Launch;

namespace EnvelopeRenderer.Desktop.Tests;

public class CliProcessLauncherTests
{
private static readonly string[] Arguments = { "--template", "t.xml", "--csv", "c.csv", "--output", "o.pdf" };

/// <summary>In-memory <see cref="ICliProcess"/> fake backed by <see cref="StringReader"/>s.</summary>
private sealed class FakeCliProcess : ICliProcess
{
private readonly int _exitCode;

public FakeCliProcess(IEnumerable<string> stdOutLines, IEnumerable<string> stdErrLines, int exitCode)
{
StandardOutput = new StringReader(string.Join('\n', stdOutLines));
StandardError = new StringReader(string.Join('\n', stdErrLines));
_exitCode = exitCode;
}

public TextReader StandardOutput { get; }

public TextReader StandardError { get; }

public Task<int> WaitForExitAsync() => Task.FromResult(_exitCode);

public void Dispose()
{
StandardOutput.Dispose();
StandardError.Dispose();
}
}

/// <summary>
/// A fake whose <see cref="WaitForExitAsync"/> blocks (on a background thread, so it never
/// deadlocks the caller) until the test releases it — used to prove <see cref="CliProcessLauncher.RunAsync"/>
/// hands the whole run off to a background thread instead of blocking the caller.
/// </summary>
private sealed class BlockingFakeCliProcess : ICliProcess
{
private readonly ManualResetEventSlim _entered;
private readonly ManualResetEventSlim _release;

public BlockingFakeCliProcess(ManualResetEventSlim entered, ManualResetEventSlim release)
{
_entered = entered;
_release = release;
}

public TextReader StandardOutput { get; } = new StringReader(string.Empty);

public TextReader StandardError { get; } = new StringReader(string.Empty);

public Task<int> WaitForExitAsync() => Task.Run(() =>
{
_entered.Set();
_release.Wait(TimeSpan.FromSeconds(5));
return 0;
});

public void Dispose()
{
StandardOutput.Dispose();
StandardError.Dispose();
}
}

/// <summary>Test double that reports synchronously, unlike the real <see cref="Progress{T}"/> (which posts
/// to a captured SynchronizationContext and may run the callback asynchronously) — needed so tests can
/// assert on reported events immediately after a synchronous <see cref="CliProcessLauncher.Run"/> call.</summary>
private sealed class SynchronousProgress<T> : IProgress<T>
{
private readonly Action<T> _callback;

public SynchronousProgress(Action<T> callback) => _callback = callback;

public void Report(T value) => _callback(value);
}

[Fact]
public void Run_SuccessfulRender_ReturnsSucceededWithFinalCompleteEvent()
{
var stdOut = new[]
{
"PROGRESS startup elapsedMs=0 completed=0",
"PROGRESS render elapsedMs=802 completed=1",
"PROGRESS render elapsedMs=1804 completed=392",
"PROGRESS complete elapsedMs=1900 completed=392",
};
var launcher = new CliProcessLauncher(_ => new FakeCliProcess(stdOut, Array.Empty<string>(), exitCode: 0));

var result = launcher.Run(@"C:\App\EnvelopeRenderer.Cli.exe", Arguments);

Assert.True(result.Started);
Assert.True(result.Succeeded);
Assert.Equal(0, result.ExitCode);
Assert.NotNull(result.FinalProgress);
Assert.Equal(ProgressEventKind.Complete, result.FinalProgress!.Kind);
Assert.Equal(392, result.FinalProgress.Completed);
Assert.Equal(1900, result.FinalProgress.ElapsedMs);
}

[Fact]
public void Run_FailedRender_ReturnsFailureWithFinalFailureEventAndStderr()
{
var stdOut = new[]
{
"PROGRESS startup elapsedMs=0 completed=0",
"PROGRESS render elapsedMs=802 completed=1",
"PROGRESS render elapsedMs=1804 completed=392",
"PROGRESS failure elapsedMs=1805 completed=392 reason=Failed to save PDF to 'out.pdf' (error code 999).",
};
var stdErr = new[] { "ERROR: Failed to save PDF to 'out.pdf' (error code 999)." };
var launcher = new CliProcessLauncher(_ => new FakeCliProcess(stdOut, stdErr, exitCode: 1));

var result = launcher.Run(@"C:\App\EnvelopeRenderer.Cli.exe", Arguments);

Assert.True(result.Started);
Assert.False(result.Succeeded);
Assert.Equal(1, result.ExitCode);
Assert.Equal(ProgressEventKind.Failure, result.FinalProgress!.Kind);
Assert.Equal("Failed to save PDF to 'out.pdf' (error code 999).", result.FinalProgress.Reason);
Assert.Single(result.StdErrLines);
Assert.Equal("ERROR: Failed to save PDF to 'out.pdf' (error code 999).", result.StdErrLines[0]);
}

[Fact]
public void Run_ReportsEachParsedProgressEventInOrderAsItArrives()
{
var stdOut = new[]
{
"PROGRESS startup elapsedMs=0 completed=0",
"PROGRESS render elapsedMs=1000 completed=100",
"PROGRESS complete elapsedMs=1500 completed=100",
};
var launcher = new CliProcessLauncher(_ => new FakeCliProcess(stdOut, Array.Empty<string>(), exitCode: 0));
var reported = new List<ProgressEventKind>();

launcher.Run(
@"C:\App\EnvelopeRenderer.Cli.exe",
Arguments,
onProgress: new SynchronousProgress<ProgressEvent>(e => reported.Add(e.Kind)));

Assert.Equal(
new[] { ProgressEventKind.Startup, ProgressEventKind.Render, ProgressEventKind.Complete },
reported);
}

[Fact]
public void Run_IgnoresBlankAndUnparsableStdoutLines()
{
var stdOut = new[]
{
"",
"some unrelated line",
"PROGRESS complete elapsedMs=10 completed=1",
};
var launcher = new CliProcessLauncher(_ => new FakeCliProcess(stdOut, Array.Empty<string>(), exitCode: 0));

var result = launcher.Run(@"C:\App\EnvelopeRenderer.Cli.exe", Arguments);

Assert.Equal(ProgressEventKind.Complete, result.FinalProgress!.Kind);
}

[Fact]
public void Run_NoProgressEventsAtAll_ReturnsResultWithNullFinalProgress()
{
// Mirrors a pre-render validation failure (exit 2/3/4): per CLI_CONTRACT.md, those
// produce no PROGRESS events at all, only stderr ERROR: lines.
var stdErr = new[] { "ERROR: Output directory does not exist: 'nosuchdir'." };
var launcher = new CliProcessLauncher(_ => new FakeCliProcess(Array.Empty<string>(), stdErr, exitCode: 4));

var result = launcher.Run(@"C:\App\EnvelopeRenderer.Cli.exe", Arguments);

Assert.True(result.Started);
Assert.False(result.Succeeded);
Assert.Equal(4, result.ExitCode);
Assert.Null(result.FinalProgress);
Assert.Single(result.StdErrLines);
}

[Fact]
public void Run_ProcessStartThrowsWin32Exception_ReturnsStartFailureWithoutStarting()
{
var launcher = new CliProcessLauncher(_ => throw new Win32Exception(2, "The system cannot find the file specified"));

var result = launcher.Run(@"C:\App\does-not-exist.exe", Arguments);

Assert.False(result.Started);
Assert.Contains("could not be started", result.StartFailureMessage);
Assert.Contains("system cannot find the file specified", result.StartFailureMessage);
Assert.False(result.Succeeded);
Assert.Null(result.ExitCode);
Assert.Null(result.FinalProgress);
}

[Fact]
public void Run_ProcessStartThrowsUnexpectedException_ReturnsStartFailureWithoutThrowing()
{
var launcher = new CliProcessLauncher(_ => throw new InvalidOperationException("boom"));

var result = launcher.Run(@"C:\App\EnvelopeRenderer.Cli.exe", Arguments);

Assert.False(result.Started);
Assert.Contains("could not be started", result.StartFailureMessage);
}

[Fact]
public async Task RunAsync_ReturnsSameOutcomeAsRun()
{
var stdOut = new[] { "PROGRESS complete elapsedMs=10 completed=1" };
var launcher = new CliProcessLauncher(_ => new FakeCliProcess(stdOut, Array.Empty<string>(), exitCode: 0));

var result = await launcher.RunAsync(@"C:\App\EnvelopeRenderer.Cli.exe", Arguments);

Assert.True(result.Succeeded);
}

[Fact]
public async Task RunAsync_DoesNotBlockCallerWhileTheProcessIsRunning()
{
// Proves RunAsync hands the whole run off to a background thread rather than blocking
// the caller (the UI thread, in the real app) until the child process exits — this is
// exactly what lets MainForm await it without freezing while a render is in flight, and
// what makes it safe to only re-enable the Render button afterward.
using var waitEntered = new ManualResetEventSlim(false);
using var releaseWait = new ManualResetEventSlim(false);

var launcher = new CliProcessLauncher(_ => new BlockingFakeCliProcess(waitEntered, releaseWait));

var task = launcher.RunAsync(@"C:\App\EnvelopeRenderer.Cli.exe", Arguments);

Assert.True(waitEntered.Wait(TimeSpan.FromSeconds(5)), "WaitForExitAsync was never invoked");
Assert.False(task.IsCompleted, "RunAsync's task completed before the simulated process exited - the call is blocking instead of running in the background.");

releaseWait.Set();
var result = await task;

Assert.True(result.Succeeded);
}
}

+ 30
- 0
code/src/EnvelopeRenderer.Desktop.Tests/CliProcessStartInfoFactoryTests.cs Parādīt failu

@@ -0,0 +1,30 @@
using EnvelopeRenderer.Desktop.Core.Launch;

namespace EnvelopeRenderer.Desktop.Tests;

public class CliProcessStartInfoFactoryTests
{
[Fact]
public void Create_SetsFileNameAndArgumentListInOrder_AndDoesNotUseShellExecute()
{
var arguments = new[] { "--template", "t.xml", "--csv", "c.csv", "--output", "o.pdf" };

var startInfo = CliProcessStartInfoFactory.Create(@"C:\App\EnvelopeRenderer.Cli.exe", arguments);

Assert.Equal(@"C:\App\EnvelopeRenderer.Cli.exe", startInfo.FileName);
Assert.Equal(arguments, startInfo.ArgumentList);
Assert.False(startInfo.UseShellExecute);
}

[Fact]
public void Create_RedirectsStdOutAndStdErr_AndHidesTheConsoleWindow()
{
// Sprint 1, Batch 5 reads the CLI's PROGRESS/ERROR: streams itself and displays them
// inside the desktop shell, so a separate visible console window is no longer needed.
var startInfo = CliProcessStartInfoFactory.Create(@"C:\App\EnvelopeRenderer.Cli.exe", Array.Empty<string>());

Assert.True(startInfo.RedirectStandardOutput);
Assert.True(startInfo.RedirectStandardError);
Assert.True(startInfo.CreateNoWindow);
}
}

+ 25
- 0
code/src/EnvelopeRenderer.Desktop.Tests/EnvelopeRenderer.Desktop.Tests.csproj Parādīt failu

@@ -0,0 +1,25 @@
<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="..\EnvelopeRenderer.Desktop.Core\EnvelopeRenderer.Desktop.Core.csproj" />
</ItemGroup>

</Project>

+ 88
- 0
code/src/EnvelopeRenderer.Desktop.Tests/ProgressEventParserTests.cs Parādīt failu

@@ -0,0 +1,88 @@
using EnvelopeRenderer.Desktop.Core.Launch;

namespace EnvelopeRenderer.Desktop.Tests;

public class ProgressEventParserTests
{
[Fact]
public void TryParse_StartupLine_ParsesFields()
{
var parsed = ProgressEventParser.TryParse("PROGRESS startup elapsedMs=0 completed=0", out var progressEvent);

Assert.True(parsed);
Assert.Equal(ProgressEventKind.Startup, progressEvent!.Kind);
Assert.Equal(0, progressEvent.ElapsedMs);
Assert.Equal(0, progressEvent.Completed);
Assert.Null(progressEvent.Reason);
}

[Fact]
public void TryParse_RenderLine_ParsesFields()
{
var parsed = ProgressEventParser.TryParse("PROGRESS render elapsedMs=802 completed=1", out var progressEvent);

Assert.True(parsed);
Assert.Equal(ProgressEventKind.Render, progressEvent!.Kind);
Assert.Equal(802, progressEvent.ElapsedMs);
Assert.Equal(1, progressEvent.Completed);
Assert.Null(progressEvent.Reason);
}

[Fact]
public void TryParse_CompleteLine_ParsesFields()
{
var parsed = ProgressEventParser.TryParse("PROGRESS complete elapsedMs=1900 completed=392", out var progressEvent);

Assert.True(parsed);
Assert.Equal(ProgressEventKind.Complete, progressEvent!.Kind);
Assert.Equal(1900, progressEvent.ElapsedMs);
Assert.Equal(392, progressEvent.Completed);
Assert.Null(progressEvent.Reason);
}

[Fact]
public void TryParse_FailureLine_CapturesReasonToEndOfLineVerbatim()
{
const string line = "PROGRESS failure elapsedMs=1805 completed=392 reason=Failed to save PDF to 'out.pdf' (error code 999).";

var parsed = ProgressEventParser.TryParse(line, out var progressEvent);

Assert.True(parsed);
Assert.Equal(ProgressEventKind.Failure, progressEvent!.Kind);
Assert.Equal(1805, progressEvent.ElapsedMs);
Assert.Equal(392, progressEvent.Completed);
Assert.Equal("Failed to save PDF to 'out.pdf' (error code 999).", progressEvent.Reason);
}

[Fact]
public void TryParse_ReasonContainingSpaces_KeepsWholeReasonTogether()
{
const string line = "PROGRESS failure elapsedMs=5 completed=0 reason=multiple words here";

var parsed = ProgressEventParser.TryParse(line, out var progressEvent);

Assert.True(parsed);
Assert.Equal("multiple words here", progressEvent!.Reason);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not a progress line")]
[InlineData("PROGRESS")]
[InlineData("PROGRESS startup")]
[InlineData("PROGRESS unknownkind elapsedMs=0 completed=0")]
[InlineData("PROGRESS startup notElapsed=0 completed=0")]
[InlineData("PROGRESS startup elapsedMs=abc completed=0")]
[InlineData("PROGRESS startup elapsedMs=0 notCompleted=0")]
[InlineData("PROGRESS startup elapsedMs=0 completed=abc")]
[InlineData("PROGRESS startup elapsedMs=0 completed=0 notreason=x")]
public void TryParse_MalformedOrUnrelatedLine_ReturnsFalseAndNullEvent(string? line)
{
var parsed = ProgressEventParser.TryParse(line, out var progressEvent);

Assert.False(parsed);
Assert.Null(progressEvent);
}
}

+ 82
- 0
code/src/EnvelopeRenderer.Desktop.Tests/RenderCompletionSummaryFormatterTests.cs Parādīt failu

@@ -0,0 +1,82 @@
using EnvelopeRenderer.Desktop.Core.Launch;

namespace EnvelopeRenderer.Desktop.Tests;

public class RenderCompletionSummaryFormatterTests
{
[Fact]
public void Format_LaunchFailure_ReturnsStartFailureMessageVerbatim()
{
var result = RenderRunResult.StartFailure("The render program could not be started.");

var text = RenderCompletionSummaryFormatter.Format(result);

Assert.Equal("The render program could not be started.", text);
}

[Fact]
public void Format_SuccessfulRender_IncludesTotalsAndElapsedTime()
{
var complete = new ProgressEvent(ProgressEventKind.Complete, 1900, 392, null);
var result = new RenderRunResult(true, null, 0, complete, Array.Empty<string>());

var text = RenderCompletionSummaryFormatter.Format(result);

Assert.Contains("392", text);
Assert.Contains("1.9s", text);
Assert.Contains("complete", text, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void Format_FailedRenderWithFailureEvent_UsesReasonElapsedAndCompletedCount()
{
var failure = new ProgressEvent(
ProgressEventKind.Failure, 1805, 392, "Failed to save PDF to 'out.pdf' (error code 999).");
var result = new RenderRunResult(
true, null, 1, failure, new[] { "ERROR: Failed to save PDF to 'out.pdf' (error code 999)." });

var text = RenderCompletionSummaryFormatter.Format(result);

Assert.Contains("Failed to save PDF to 'out.pdf' (error code 999).", text);
Assert.Contains("392", text);
Assert.Contains("1.8s", text);
Assert.Contains("failed", text, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void Format_FailedRenderWithNoProgressEvents_FallsBackToStderrLines()
{
var result = new RenderRunResult(true, null, 4, null, new[] { "ERROR: Output directory does not exist: 'nosuchdir'." });

var text = RenderCompletionSummaryFormatter.Format(result);

Assert.Contains("Output directory does not exist", text);
}

[Fact]
public void Format_FailedRenderWithNoProgressEventsOrStderr_ReturnsGenericMessageWithExitCode()
{
var result = new RenderRunResult(true, null, 1, null, Array.Empty<string>());

var text = RenderCompletionSummaryFormatter.Format(result);

Assert.Contains("1", text);
Assert.Contains("failed", text, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void Format_DoesNotFabricateAWarningCount()
{
// Documents the resolved ambiguity: the CLI/render pipeline exposes no warning concept
// today (see RenderCompletionSummaryFormatter's doc comment), so the summary must never
// claim one exists.
var complete = new ProgressEvent(ProgressEventKind.Complete, 1900, 392, null);
var successResult = new RenderRunResult(true, null, 0, complete, Array.Empty<string>());

var failure = new ProgressEvent(ProgressEventKind.Failure, 1805, 392, "boom");
var failureResult = new RenderRunResult(true, null, 1, failure, Array.Empty<string>());

Assert.DoesNotContain("warning", RenderCompletionSummaryFormatter.Format(successResult), StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("warning", RenderCompletionSummaryFormatter.Format(failureResult), StringComparison.OrdinalIgnoreCase);
}
}

+ 84
- 0
code/src/EnvelopeRenderer.Desktop.Tests/RenderLaunchValidatorTests.cs Parādīt failu

@@ -0,0 +1,84 @@
using EnvelopeRenderer.Desktop.Core.Launch;

namespace EnvelopeRenderer.Desktop.Tests;

public class RenderLaunchValidatorTests
{
[Fact]
public void Validate_AllFieldsPresent_IsValid()
{
var inputs = new RenderLaunchInputs("template.xml", "data.csv", "out.pdf");

var result = RenderLaunchValidator.Validate(inputs);

Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_MissingTemplatePath_ReportsFriendlyError(string? templatePath)
{
var inputs = new RenderLaunchInputs(templatePath, "data.csv", "out.pdf");

var result = RenderLaunchValidator.Validate(inputs);

Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("template", StringComparison.OrdinalIgnoreCase));
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_MissingCsvPath_ReportsFriendlyError(string? csvPath)
{
var inputs = new RenderLaunchInputs("template.xml", csvPath, "out.pdf");

var result = RenderLaunchValidator.Validate(inputs);

Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("CSV", StringComparison.OrdinalIgnoreCase));
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_MissingOutputPath_ReportsFriendlyError(string? outputPath)
{
var inputs = new RenderLaunchInputs("template.xml", "data.csv", outputPath);

var result = RenderLaunchValidator.Validate(inputs);

Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("PDF", StringComparison.OrdinalIgnoreCase));
}

[Fact]
public void Validate_AllFieldsMissing_ReportsOneErrorPerField()
{
var inputs = new RenderLaunchInputs(null, null, null);

var result = RenderLaunchValidator.Validate(inputs);

Assert.False(result.IsValid);
Assert.Equal(3, result.Errors.Count);
}

[Fact]
public void Validate_Errors_DoNotLookLikeRawExceptionMessages()
{
var inputs = new RenderLaunchInputs(null, null, null);

var result = RenderLaunchValidator.Validate(inputs);

Assert.All(result.Errors, e =>
{
Assert.DoesNotContain("Exception", e);
Assert.DoesNotContain("System.", e);
});
}
}

+ 49
- 0
code/src/EnvelopeRenderer.Desktop.Tests/RenderProgressStatusFormatterTests.cs Parādīt failu

@@ -0,0 +1,49 @@
using EnvelopeRenderer.Desktop.Core.Launch;

namespace EnvelopeRenderer.Desktop.Tests;

public class RenderProgressStatusFormatterTests
{
[Fact]
public void Format_Startup_ReturnsFriendlyStartingMessage()
{
var text = RenderProgressStatusFormatter.Format(new ProgressEvent(ProgressEventKind.Startup, 0, 0, null));

Assert.Contains("Starting", text, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void Format_Render_IncludesCompletedCountAndElapsedTime()
{
var text = RenderProgressStatusFormatter.Format(new ProgressEvent(ProgressEventKind.Render, 1804, 392, null));

Assert.Contains("392", text);
Assert.Contains("1.8s", text);
}

[Fact]
public void Format_NeverLeaksRawContractFieldNames()
{
var text = RenderProgressStatusFormatter.Format(new ProgressEvent(ProgressEventKind.Render, 1804, 392, null));

Assert.DoesNotContain("elapsedMs", text);
Assert.DoesNotContain("completed=", text);
}

[Fact]
public void Format_Complete_MentionsRecordCount()
{
var text = RenderProgressStatusFormatter.Format(new ProgressEvent(ProgressEventKind.Complete, 1900, 392, null));

Assert.Contains("392", text);
}

[Fact]
public void Format_Failure_IsNonTechnical()
{
var text = RenderProgressStatusFormatter.Format(
new ProgressEvent(ProgressEventKind.Failure, 1805, 392, "Failed to save PDF to 'out.pdf' (error code 999)."));

Assert.Contains("failed", text, StringComparison.OrdinalIgnoreCase);
}
}

+ 25
- 0
code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj Parādīt failu

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\EnvelopeRenderer.Desktop.Core\EnvelopeRenderer.Desktop.Core.csproj" />

<!--
Referenced only so `dotnet build`/`dotnet run` for this project always produce a freshly
built EnvelopeRenderer.Cli.exe copied next to EnvelopeRenderer.Desktop.exe — exactly where
Launch/CliExecutablePathResolver.cs looks by default. The desktop app never calls into
EnvelopeRenderer.Cli's code directly; it only launches its built .exe as a child process
(see Launch/CliProcessLauncher.cs). Do not add a using/reference to its internals — the
contract between the two processes is CLI_CONTRACT.md, not shared code.
-->
<ProjectReference Include="..\EnvelopeRenderer.Cli\EnvelopeRenderer.Cli.csproj" />
</ItemGroup>

</Project>

+ 213
- 0
code/src/EnvelopeRenderer.Desktop/MainForm.cs Parādīt failu

@@ -0,0 +1,213 @@
using System.Drawing;
using System.Windows.Forms;
using EnvelopeRenderer.Desktop.Core.Launch;

namespace EnvelopeRenderer.Desktop;

/// <summary>
/// Operator-facing shell: pick a template, CSV, and output PDF path, then launch a render and
/// watch it through to completion. Sprint 1, Batch 4 ("Launch a text-only render from the
/// desktop app") proved the process could be launched without freezing the UI; Batch 5 ("Show
/// render progress and completion summary") adds reading the CLI's `PROGRESS` stream while it
/// runs, showing a non-technical completion summary once it exits, and — since the button is now
/// only re-enabled after the process actually exits rather than as soon as it starts — closes the
/// double-launch gap previously logged in logs/technical_debt_log.md.
///
/// All non-UI logic (input validation, argument construction, CLI discovery, process launch,
/// progress parsing, and summary formatting) lives in EnvelopeRenderer.Desktop.Core.Launch and is
/// unit tested there — this class is intentionally thin event-handler wiring over that logic.
/// </summary>
public sealed class MainForm : Form
{
private readonly TextBox _templatePathTextBox = new();
private readonly TextBox _csvPathTextBox = new();
private readonly TextBox _outputPathTextBox = new();
private readonly Button _renderButton = new() { Text = "&Render", AutoSize = true };
private readonly Label _statusLabel = new()
{
AutoSize = false,
Height = 48,
TextAlign = ContentAlignment.TopLeft,
};

private readonly CliProcessLauncher _launcher;

public MainForm() : this(new CliProcessLauncher())
{
}

/// <summary>Internal constructor allowing a launcher to be injected (used by tests/composition).</summary>
internal MainForm(CliProcessLauncher launcher)
{
_launcher = launcher;

Text = "Envelope Renderer";
MinimumSize = new Size(640, 260);
StartPosition = FormStartPosition.CenterScreen;

Controls.Add(BuildLayout());
}

private Control BuildLayout()
{
var layout = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 3,
RowCount = 5,
Padding = new Padding(12),
AutoSize = true,
};
layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));

AddPickerRow(
layout,
row: 0,
labelText: "Template (XML):",
textBox: _templatePathTextBox,
browse: () => BrowseForOpenFile(_templatePathTextBox, "XML template files (*.xml)|*.xml|All files (*.*)|*.*"));

AddPickerRow(
layout,
row: 1,
labelText: "CSV data:",
textBox: _csvPathTextBox,
browse: () => BrowseForOpenFile(_csvPathTextBox, "CSV files (*.csv)|*.csv|All files (*.*)|*.*"));

AddPickerRow(
layout,
row: 2,
labelText: "Output PDF:",
textBox: _outputPathTextBox,
browse: () => BrowseForSaveFile(_outputPathTextBox, "PDF files (*.pdf)|*.pdf|All files (*.*)|*.*"));

_renderButton.Click += OnRenderClick;
var buttonPanel = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft, AutoSize = true };
buttonPanel.Controls.Add(_renderButton);
layout.Controls.Add(buttonPanel, 1, 3);
layout.SetColumnSpan(buttonPanel, 2);

_statusLabel.Dock = DockStyle.Fill;
layout.Controls.Add(_statusLabel, 0, 4);
layout.SetColumnSpan(_statusLabel, 3);

return layout;
}

private static void AddPickerRow(TableLayoutPanel layout, int row, string labelText, TextBox textBox, Action browse)
{
var label = new Label
{
Text = labelText,
AutoSize = true,
Anchor = AnchorStyles.Left,
Margin = new Padding(3, 9, 3, 3),
};

textBox.Dock = DockStyle.Fill;
textBox.Margin = new Padding(3, 6, 3, 3);

var browseButton = new Button { Text = "Browse...", AutoSize = true, Margin = new Padding(3, 3, 3, 3) };
browseButton.Click += (_, _) => browse();

layout.Controls.Add(label, 0, row);
layout.Controls.Add(textBox, 1, row);
layout.Controls.Add(browseButton, 2, row);
}

private static void BrowseForOpenFile(TextBox target, string filter)
{
using var dialog = new OpenFileDialog { Filter = filter, CheckFileExists = true, Title = "Select a file" };
if (dialog.ShowDialog() == DialogResult.OK)
{
target.Text = dialog.FileName;
}
}

private static void BrowseForSaveFile(TextBox target, string filter)
{
using var dialog = new SaveFileDialog { Filter = filter, OverwritePrompt = false, Title = "Choose where to save the PDF" };
if (dialog.ShowDialog() == DialogResult.OK)
{
target.Text = dialog.FileName;
}
}

private async void OnRenderClick(object? sender, EventArgs e)
{
var inputs = new RenderLaunchInputs(_templatePathTextBox.Text, _csvPathTextBox.Text, _outputPathTextBox.Text);

var validation = RenderLaunchValidator.Validate(inputs);
if (!validation.IsValid)
{
SetStatus(string.Join(" ", validation.Errors), isError: true);
return;
}

var cliPath = CliExecutablePathResolver.Resolve(AppContext.BaseDirectory);
if (!cliPath.IsFound)
{
SetStatus(cliPath.Error!, isError: true);
ShowLaunchFailure(cliPath.Error!);
return;
}

// Disabled here and only re-enabled once RunAsync's awaited Task completes below (i.e.
// once the child process has actually exited) — not the moment it starts — so an
// operator can't launch a second render against the same output while one is in flight.
_renderButton.Enabled = false;
SetStatus("Starting render...", isError: false);

try
{
var arguments = CliArgumentListBuilder.Build(inputs);

// Constructed on the UI thread so Progress<T> captures this thread's
// SynchronizationContext and marshals each Report() call back to it automatically —
// OnRenderProgress can update controls directly without a manual Invoke.
var progress = new Progress<ProgressEvent>(OnRenderProgress);

var result = await _launcher.RunAsync(cliPath.Path!, arguments, progress);

var summary = RenderCompletionSummaryFormatter.Format(result);
if (result.Succeeded)
{
SetStatus(summary, isError: false);
}
else if (!result.Started)
{
// A launch failure (the process never started) keeps the message-box treatment
// from Batch 4 — it means something is wrong with the desktop app's own setup,
// not with the operator's data, and deserves an explicit acknowledgement.
SetStatus(summary, isError: true);
ShowLaunchFailure(summary);
}
else
{
SetStatus(summary, isError: true);
}
}
finally
{
_renderButton.Enabled = true;
}
}

private void OnRenderProgress(ProgressEvent progressEvent)
{
SetStatus(RenderProgressStatusFormatter.Format(progressEvent), isError: progressEvent.Kind == ProgressEventKind.Failure);
}

private void ShowLaunchFailure(string message)
{
MessageBox.Show(this, message, "Could not start render", MessageBoxButtons.OK, MessageBoxIcon.Error);
}

private void SetStatus(string message, bool isError)
{
_statusLabel.Text = message;
_statusLabel.ForeColor = isError ? Color.Firebrick : Color.DarkGreen;
}
}

+ 12
- 0
code/src/EnvelopeRenderer.Desktop/Program.cs Parādīt failu

@@ -0,0 +1,12 @@
namespace EnvelopeRenderer.Desktop;

internal static class Program
{
/// <summary>The main entry point for the desktop application.</summary>
[STAThread]
private static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
}
}

+ 1
- 0
logs/technical_debt_log.md Parādīt failu

@@ -6,3 +6,4 @@ Append-only log of known technical debt. Maintained by `.claude/agents/qa-tech-d
|---|---|---|---|---|---|
| 2026-09-08 | `EnvelopeRenderer.Cli` exits `64` ("render not implemented") for any argument-valid run, since the Debenu render engine isn't wired in yet. Self-documenting and expected to disappear once "Render text-only PDFs through Debenu Quick PDF" (Sprint 1 Batch 2) lands and exit `0` becomes reachable. | Deliberate | Low | Paid Down | `RenderNotImplemented` branch removed from `Program.cs`; exit `0` is now reachable and exit `1` covers all template/CSV/render failures (see `code/CLI_CONTRACT.md`). |
| 2026-09-08 | `DebenuPdfRenderer` always embeds TrueType fonts fully (`AddTrueTypeFont(..., Embed: 1)`), cached once per unique font name per document. Fine at today's scale (one sample render: ~1.3 MB for 392 pages, one font) but full embedding could add up if a template ever uses many distinct fonts/styles at very high page counts, working against the sub-2GB PDF constraint. | Deliberate | Low | Open | Revisit if the "Time-box the first high-volume benchmark" story (not in this sprint) shows file size becoming an issue; Debenu also exposes `AddSubsettedFont` as a smaller-footprint alternative if needed. |
| 2026-09-08 | `EnvelopeRenderer.Desktop`'s Render button re-enables as soon as the CLI process is confirmed *started* (`CliProcessLauncher.LaunchAsync` returning), not once it finishes rendering — this story (Sprint 1 Batch 4) intentionally does not track render completion. An operator can click Render again (e.g. against the same output path) while a prior render is still in progress, since nothing yet observes the child process's lifetime or exit code. | Unavoidable (scope boundary of this story) | Low | Resolved | Sprint 1 Batch 5 ("Show render progress and completion summary") replaced `CliProcessLauncher.LaunchAsync`/`Launch` with `RunAsync`/`Run`, which stream the CLI's redirected stdout/stderr, wait for the process to actually exit, and return exit code + final progress event. `MainForm.OnRenderClick` now only re-enables the Render button in the `finally` block after that awaited call completes, not when the process starts — verified with a real launch against the sample CSV (button stayed disabled for the full render, both success and forced-failure runs). |

+ 1
- 1
project_config.md Parādīt failu

@@ -4,7 +4,7 @@

- **Project name:** Envelope Printing Report Generator
- **Primary user / vision:** Non-technical print operators can visually design envelope layouts, map CSV data, preview records, and generate print-ready PDFs without developer intervention.
- **Tech stack:** Native Windows desktop application using `C#` on `.NET 10`; desktop UI framework still to be confirmed; fixed integration points are Debenu Quick PDF Library `10.13` (64-bit DLL), XML-based templates, CSV input with header rows, and support for local plus UNC file paths. CSV parsing uses `CsvHelper` (chosen by `dev-team` during Sprint 1, Batch 2 — the real sample data has quoted commas in address fields, which a naive split would break). Rendering requires a Debenu license key at runtime (`DEBENU_LICENSE_KEY`); see `code/CLI_CONTRACT.md`.
- **Tech stack:** Native Windows desktop application using `C#` on `.NET 10`; desktop UI framework is `WinForms` (confirmed by the user ahead of Sprint 1, Batch 4 — simplest fit for file pickers and a launch action, matches the team's scaffolding pace). Fixed integration points are Debenu Quick PDF Library `10.13` (64-bit DLL), XML-based templates, CSV input with header rows, and support for local plus UNC file paths. CSV parsing uses `CsvHelper` (chosen by `dev-team` during Sprint 1, Batch 2 — the real sample data has quoted commas in address fields, which a naive split would break). Rendering requires a Debenu license key at runtime (`DEBENU_LICENSE_KEY`); see `code/CLI_CONTRACT.md`.
- **Test framework(s):** `xUnit` (chosen by `dev-team` during Sprint 1, Batch 1 — the standard default for .NET SDK-style projects, no reason to deviate). Minimum validation baseline from the requirements is self-tested sample CSV/render scenarios, including overflow, missing-font, missing-image, and high-volume render checks.
- **Code review process:** Formal reviewer is not defined in the requirements; until a team process is set, changes require self-review against acceptance criteria and Definition of Done before being considered complete.
- **CI pipeline:** No CI pipeline is defined in the requirements; initial expectation is local build and verification gates, with CI setup to be planned as part of early delivery work.


+ 5
- 4
state.md Parādīt failu

@@ -2,16 +2,16 @@

> The single live "where are we right now" file. Read this FIRST at the start of any session that touches this repo - don't infer phase or sprint from conversation history. Updated LAST by whichever agent completes the current step, as documented in `AGENTS.md` under "Automated State-Driven Handoff."

**Phase:** 3 - Sprint execution
**Leading agent:** `dev-team`
**Process file:** `process/03_sprint_execution.md`
**Phase:** 4 - Sprint review
**Leading agent:** `product-owner`
**Process file:** `process/04_sprint_review.md`

**Sprint:** 1
**Sprint dates:** 2026-09-08 - 2026-09-11
**Sprint goal:** Prove a thin text-only desktop-to-CLI rendering spine so an operator can select files, launch a job, watch progress, and receive a PDF generated through Debenu from a known XML template.
**Current sprint backlog:** `backlog/sprints/sprint-1.md`

**Next action:** Batches 1 and 2 are Done. The CLI now renders real text-only PDFs through Debenu Quick PDF 10.13 (`code/CLI_CONTRACT.md`, `code/TEMPLATE_FORMAT.md`), verified end-to-end against the 392-row sample CSV (32 tests passing, including one real-DLL integration test). Rendering requires `DEBENU_LICENSE_KEY` at runtime — see `code/CLI_CONTRACT.md`. `dev-team` pulls Batch 3 ("Emit machine-readable progress during render") next. The template-path and UNC-timeout impediments in `logs/impediment_log.md` are still open and unaffected by this batch.
**Next action:** All 5 committed Sprint 1 items are Done (exit criteria for Phase 3 met — see `backlog/sprints/sprint-1.md`). The walking skeleton is real end-to-end: an operator picks template/CSV/output paths in the WinForms desktop app (`EnvelopeRenderer.Desktop`), launches a render without freezing the UI, watches live progress parsed from the CLI's `PROGRESS <kind> ...` stdout stream, and gets a completion summary — verified with a real success run (392/392 pages, valid PDF) and a real forced-failure run against the actual sample CSV. 106 tests passing. `product-owner` runs Sprint Review next per `process/04_sprint_review.md`. Carry-forward items to raise at review: template-path and UNC-timeout impediments still open (`logs/impediment_log.md`); the 100k-record/300 DPI benchmark hasn't been re-run since Batch 2; no "warning" concept exists in the product yet, so the "warning count" acceptance task for the last story was satisfied by its documented absence rather than a real counter — product owner may want to scope that once image/overflow handling exists.

## Phase reference

@@ -37,3 +37,4 @@ After phase 5, loop back to phase 1 for the next sprint.
| 2026-09-04 | 1 - Backlog refinement | Refined the top epics into sprint-ready stories, confirmed `C#/.NET 10` as the application stack, and reordered the backlog for the first text-only MVP slice. |
| 2026-09-04 | 2 - Sprint planning | Created Sprint 1 around a conservative desktop-to-CLI text-only rendering spine and recorded the committed backlog in `backlog/sprints/sprint-1.md`. |
| 2026-09-08 | 3 - Sprint execution (in progress) | Held Daily Scrum #1 and turned the sprint backlog into a dependency-ordered execution plan (5 batches) in `backlog/sprints/sprint-1.md`. Still in phase 3 — not a phase transition, just the day's checkpoint. |
| 2026-09-08 | 3 - Sprint execution | All 5 batches Done: CLI render contract, real Debenu text-only rendering, stdout progress events, WinForms desktop launch shell (user-confirmed as the UI framework), and live progress/completion display. 106 tests passing end-to-end. Sprint goal met with real verified runs against the 392-row sample CSV. |

Notiek ielāde…
Atcelt
Saglabāt

Powered by TurnKey Linux.