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
| @@ -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. | |||||
| @@ -1,2 +1,4 @@ | |||||
| bin/ | bin/ | ||||
| obj/ | obj/ | ||||
| .idea | |||||
| *.DotSettings.user | |||||
| @@ -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,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> | ||||
| @@ -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`. | ||||
| @@ -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]); | |||||
| } | |||||
| } | |||||
| @@ -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)); | ||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| } | } | ||||
| @@ -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; | ||||
| @@ -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)); | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| @@ -0,0 +1,29 @@ | |||||
| namespace EnvelopeRenderer.Cli.Progress; | |||||
| /// <summary> | |||||
| /// Formats a single progress event as one line of plain `PROGRESS <kind> 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; | |||||
| } | |||||
| } | |||||
| @@ -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) | ||||
| @@ -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> | |||||
| @@ -0,0 +1,26 @@ | |||||
| namespace EnvelopeRenderer.Desktop.Core.Launch; | |||||
| /// <summary> | |||||
| /// Builds the CLI argument list from validated <see cref="RenderLaunchInputs"/>, matching the | |||||
| /// `--template <path> --csv <path> --output <path>` 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!, | |||||
| }; | |||||
| } | |||||
| } | |||||
| @@ -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."); | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -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"; | |||||
| } | |||||
| @@ -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(); | |||||
| } | |||||
| @@ -0,0 +1,8 @@ | |||||
| namespace EnvelopeRenderer.Desktop.Core.Launch; | |||||
| /// <summary> | |||||
| /// A single parsed `PROGRESS <kind> elapsedMs=<n> completed=<n> [reason=<text>]` | |||||
| /// 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); | |||||
| @@ -0,0 +1,12 @@ | |||||
| namespace EnvelopeRenderer.Desktop.Core.Launch; | |||||
| /// <summary> | |||||
| /// Mirrors the `PROGRESS <kind> ...` event kinds documented in ../../../CLI_CONTRACT.md. | |||||
| /// </summary> | |||||
| public enum ProgressEventKind | |||||
| { | |||||
| Startup, | |||||
| Render, | |||||
| Complete, | |||||
| Failure, | |||||
| } | |||||
| @@ -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 <kind> elapsedMs=<n> completed=<n> [reason=<text>]` 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); | |||||
| } | |||||
| } | |||||
| @@ -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(); | |||||
| } | |||||
| @@ -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."; | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| @@ -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); | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| @@ -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, | |||||
| }; | |||||
| } | |||||
| @@ -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>()); | |||||
| } | |||||
| @@ -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]); | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| @@ -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> | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| }); | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| @@ -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> | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -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()); | |||||
| } | |||||
| } | |||||
| @@ -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). | | |||||
| @@ -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. | ||||
| @@ -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. | | |||||
Powered by TurnKey Linux.