Sfoglia il codice sorgente

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 1 settimana fa
parent
commit
91f86dd473
47 ha cambiato i file con 2034 aggiunte e 20 eliminazioni
  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 Vedi File

@@ -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. | | 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. | | 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 ## 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. - 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 | 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 (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 | "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 ## 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. | | 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. | | 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 Vedi File

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

+ 76
- 7
code/CLI_CONTRACT.md Vedi File

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


## stdout / stderr rules ## 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. - **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. 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 ## Exit codes


@@ -91,11 +144,27 @@ $ dotnet run --project src/EnvelopeRenderer.Cli -- --template envelope.xml --csv
ERROR: Output directory does not exist: 'nosuchdir'. ERROR: Output directory does not exist: 'nosuchdir'.
exit=4 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). ERROR: Failed to save PDF to 'out.pdf' (error code 999).
exit=1 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 $ 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 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 Vedi File

@@ -3,5 +3,8 @@
<Project Path="src/EnvelopeRenderer.Cli.Tests/EnvelopeRenderer.Cli.Tests.csproj" /> <Project Path="src/EnvelopeRenderer.Cli.Tests/EnvelopeRenderer.Cli.Tests.csproj" />
<Project Path="src/EnvelopeRenderer.Cli/EnvelopeRenderer.Cli.csproj" /> <Project Path="src/EnvelopeRenderer.Cli/EnvelopeRenderer.Cli.csproj" />
<Project Path="src/EnvelopeRenderer.Debenu/EnvelopeRenderer.Debenu.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> </Folder>
</Solution> </Solution>

+ 39
- 0
code/README.md Vedi File

@@ -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.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.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.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 dotnet build EnvelopeRenderer.slnx
@@ -22,6 +25,42 @@ DEBENU_LICENSE_KEY="$(cat ../key.txt)" dotnet run --project src/EnvelopeRenderer
--output out.pdf --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 ## Seed assets


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


+ 113
- 0
code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs Vedi File

@@ -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 Vedi File

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


namespace EnvelopeRenderer.Cli.Tests; namespace EnvelopeRenderer.Cli.Tests;
@@ -13,6 +14,20 @@ namespace EnvelopeRenderer.Cli.Tests;
/// </summary> /// </summary>
public class DebenuPdfRendererIntegrationTests 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() private static string? FindRepoCodeRoot()
{ {
var directory = new DirectoryInfo(AppContext.BaseDirectory); var directory = new DirectoryInfo(AppContext.BaseDirectory);
@@ -60,15 +75,17 @@ public class DebenuPdfRendererIntegrationTests
Assert.True(created, createError); Assert.True(created, createError);


var outputPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.pdf"); var outputPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.pdf");
var progress = new RecordingProgressReporter();
try try
{ {
using (renderer) using (renderer)
{ {
var result = RenderEngine.Render( 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.True(result.Succeeded, string.Join("; ", result.Errors));
Assert.Equal(expectedRecordCount, result.RecordsRendered); Assert.Equal(expectedRecordCount, result.RecordsRendered);
Assert.Equal(Enumerable.Range(1, expectedRecordCount), progress.RenderProgressCalls);
} }


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


+ 48
- 0
code/src/EnvelopeRenderer.Cli.Tests/ProgressEventFormatterTests.cs Vedi File

@@ -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 Vedi File

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


namespace EnvelopeRenderer.Cli.Tests; namespace EnvelopeRenderer.Cli.Tests;


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


Assert.True(result.Succeeded); 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 Vedi File

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


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


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

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


@@ -63,7 +68,9 @@ static int RunRender(CliArguments args)
} }
catch (Exception ex) 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; return ExitCodes.UnexpectedError;
} }


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


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


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

progress.Complete(renderResult.RecordsRendered);
} }


return ExitCodes.Success; return ExitCodes.Success;


+ 62
- 0
code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

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

namespace EnvelopeRenderer.Cli.Render; namespace EnvelopeRenderer.Cli.Render;


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


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


if (recordCount == 0) if (recordCount == 0)


+ 17
- 0
code/src/EnvelopeRenderer.Desktop.Core/EnvelopeRenderer.Desktop.Core.csproj Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 Vedi File

@@ -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 | `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 | `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 Vedi File

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


- **Project name:** Envelope Printing Report Generator - **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. - **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. - **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. - **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. - **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 Vedi File

@@ -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." > 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:** 1
**Sprint dates:** 2026-09-08 - 2026-09-11 **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. **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` **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 ## 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 | 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-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 (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. |

Loading…
Annulla
Salva

Powered by TurnKey Linux.