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. | | |||
| | Render text-only PDFs through Debenu Quick PDF | 8 points | Done | - [x] Wire Debenu Quick PDF Library `10.13` into the CLI runtime path. <br> - [x] Parse the supported text-only XML template elements needed for page and text placement. <br> - [x] Load CSV rows and merge static plus dynamic text into the render model. <br> - [x] Generate PDF `1.4+` RGB output and write it to the requested output path. <br> - [x] Verify the render path with a representative sample template and CSV dataset. | | |||
| | Emit machine-readable progress during render | 3 points | Not Started | - [ ] Define a simple line-oriented progress event format that the desktop app can parse. <br> - [ ] Emit startup, active-render, completion, and failure progress events without corrupting error output. <br> - [ ] Throttle active progress output to at least once per second and document examples. | | |||
| | Launch a text-only render from the desktop app | 3 points | Not Started | - [ ] Scaffold the desktop shell with template, CSV, and output pickers plus a launch action. <br> - [ ] Validate required operator inputs before the render starts. <br> - [ ] Start the CLI process with the selected paths and surface clear launch failures. | | |||
| | Show render progress and completion summary | 3 points | Not Started | - [ ] Parse the CLI progress stream in the desktop app without freezing the UI. <br> - [ ] Display active status updates whenever progress events arrive. <br> - [ ] Show success totals and failure outcomes, including elapsed time and warning count. | | |||
| | Emit machine-readable progress during render | 3 points | Done | - [x] Define a simple line-oriented progress event format that the desktop app can parse. <br> - [x] Emit startup, active-render, completion, and failure progress events without corrupting error output. <br> - [x] Throttle active progress output to at least once per second and document examples. | | |||
| | Launch a text-only render from the desktop app | 3 points | Done | - [x] Scaffold the desktop shell with template, CSV, and output pickers plus a launch action. <br> - [x] Validate required operator inputs before the render starts. <br> - [x] Start the CLI process with the selected paths and surface clear launch failures. | | |||
| | Show render progress and completion summary | 3 points | Done | - [x] Parse the CLI progress stream in the desktop app without freezing the UI. <br> - [x] Display active status updates whenever progress events arrive. <br> - [x] Show success totals and failure outcomes, including elapsed time and warning count (no warning concept exists in the product yet — see Day 1 log; not fabricated). | | |||
| ## Notes | |||
| - Capacity signal: first sprint with no historical velocity, so the team uses a conservative one-week forecast of about `32` task-hours for committed feature work and keeps roughly `10%` of the week for refinement and ceremony. | |||
| @@ -28,6 +28,9 @@ | |||
| | 1 | 2026-09-08 | N/A (sprint start) | Swarm on "Define the CLI render contract" (all other stories depend on its arg/exit-code shape) | Template path and UNC timeout impediments still open (see `logs/impediment_log.md`); not blocking today, will block Story 2 if still open when it starts | | |||
| | 1 (end of day) | 2026-09-08 | "Define the CLI render contract" — all 4 tasks done, story meets DoD, marked Done. `EnvelopeRenderer.Cli` scaffolded with contract doc, arg parsing/validation, exit codes 0/1/2/3/4/64, 15 passing xUnit tests, manual smoke examples recorded in `code/CLI_CONTRACT.md`. | Pull Batch 2, "Render text-only PDFs through Debenu Quick PDF" | Template path and UNC timeout impediments still open — now directly relevant since Batch 2 is the story they were flagged against; logged one low-impact, self-resolving debt item (exit code `64` placeholder) in `logs/technical_debt_log.md` | | |||
| | 1 (continued) | 2026-09-08 | "Render text-only PDFs through Debenu Quick PDF" — all 5 tasks done, story meets DoD, marked Done. Hit and resolved a real blocker mid-task: the vendored Debenu DLL fails every save (error 999) without a license key; found and verified a working key. Discovered via the vendor reference guide that `SetPageSize` has no US envelope sizes, so the template format uses exact `pageWidth`/`pageHeight` points via `SetPageDimensions` instead — also caught and fixed a real bug this surfaced (a fresh Debenu document already has page 1, so the original code would have produced a spurious blank leading page on every render). Verified end-to-end against the real 392-row sample CSV: exact `%PDF-1.4`, 392/392 pages, correct #10 envelope dimensions. Exit-code `64` placeholder removed (debt paid down); exit `1` now covers all template/CSV/render failures. | Pull Batch 3, "Emit machine-readable progress during render" | Template path and UNC timeout impediments still open, unchanged by this batch. Debenu license key requirement now documented in `code/CLI_CONTRACT.md`; logged as a resolved impediment in `logs/impediment_log.md`. | | |||
| | 1 (continued) | 2026-09-08 | "Emit machine-readable progress during render" — all 3 tasks done, story meets DoD, marked Done. Added a plain-text, line-oriented `PROGRESS <kind> elapsedMs=<n> completed=<n> [reason=<text>]` event format on stdout (`startup`/`render`/`complete`/`failure`), documented with rationale in `code/CLI_CONTRACT.md`. `render` events throttle to at least once per second via `ConsoleProgressReporter`; `RenderEngine` reports per-record unconditionally and lets the reporter decide what actually gets written. `failure` events supplement (never replace) the existing stderr `ERROR: ` line and unchanged exit codes — verified live against the real 392-row sample CSV (startup → two throttled render lines → failure, with stderr/exit code untouched). 47/47 tests passing (15 new). Did not re-run the 100k-record/300 DPI benchmark for this story specifically (judged negligible regression risk: one delegate call added per record) — flagged as an open release-quality check, not a blocker for this story. | Pull Batch 4, "Launch a text-only render from the desktop app" | Template path and UNC timeout impediments still open, unchanged by this batch. Release-quality 100k-record benchmark not re-verified since Batch 2 — worth checking before Sprint 1 review if a batch touches the render hot path again. | | |||
| | 1 (continued) | 2026-09-08 | "Launch a text-only render from the desktop app" — all 3 tasks done, story meets DoD, marked Done. User confirmed the open "UI framework still to be confirmed" item as **WinForms** (recorded in `project_config.md`). Added `EnvelopeRenderer.Desktop` (WinForms shell: three file pickers + Render button) plus `EnvelopeRenderer.Desktop.Core` (UI-independent, testable validation/argument-building/process-launch logic) and `EnvelopeRenderer.Desktop.Tests` (27 new xUnit tests). Launch runs via `Task.Run` so the UI thread is never blocked; launch failures (e.g. CLI executable not found) surface via inline status text and a message box, distinct from render failures which remain out of scope for this story. Actually built and ran the GUI end-to-end (blank-field validation, real launch, forced launch-failure via `ENVELOPERENDERER_CLI_PATH`) and confirmed correct behavior in all three cases. 74/74 tests passing (27 new + 47 unchanged). Logged one in-scope-boundary debt item in `logs/technical_debt_log.md`: the Render button re-enables once the CLI process starts, not once it finishes, so nothing yet guards against a double-launch — explicitly deferred to Batch 5, which will track process lifetime/exit code anyway. | Pull Batch 5, "Show render progress and completion summary" | Template path and UNC timeout impediments still open, unchanged by this batch. Release-quality 100k-record benchmark still not re-verified since Batch 2. Double-launch guard intentionally deferred to Batch 5 (see technical debt log). | | |||
| | 1 (continued) | 2026-09-08 | "Show render progress and completion summary" — all 3 tasks done, story meets DoD, marked Done. **This was the last batch of Sprint 1.** Desktop app now streams and parses the CLI's `PROGRESS <kind> ...` stdout events live (background async reads, `Progress<T>` marshaling back to the UI thread — never freezes the UI), shows active status while rendering, and shows a completion summary (elapsed time, records rendered, success/failure reason) on process exit. Closed the double-launch debt from Batch 4: Render button now only re-enables after the process actually exits, not when it starts — marked Resolved in `logs/technical_debt_log.md`. Investigated the "warning count" task explicitly: no warning concept exists anywhere in the CLI/render pipeline today (the only related idea, missing-image placeholders, belongs to an unbuilt future image-handling epic) — reported as a real gap rather than fabricating a counter; the summary reports only real fields. Verified end-to-end against the real 392-row sample CSV with both a real success run (valid `DEBENU_LICENSE_KEY`, real 1.28 MB PDF produced) and a real forced-failure run (no key) — both showed correct live status and correct final summaries. 106/106 tests passing (32 new). | Sprint 1 committed backlog is now fully Done — hand off to Sprint Review (`product-owner` leads, `process/04_sprint_review.md`). | Template path and UNC timeout impediments still open (see `logs/impediment_log.md`) — carry into Sprint 2 planning. Two items to surface at Sprint Review: (1) the 100k-record/300 DPI benchmark has not been re-run since Batch 2, (2) no "warning" concept exists in the product yet, so the "warning count" acceptance task is satisfied by its absence rather than a real counter — product owner may want to scope that properly in a future story once image/overflow handling exists. | | |||
| ## Execution Order | |||
| @@ -41,4 +44,4 @@ Sequenced by dependency, not by story-list order (which happens to match here si | |||
| | 4 | Launch a text-only render from the desktop app | Only hard dependency is Batch 1's contract (needs real arg names/paths to launch against). Scaffolding the shell and input pickers can start as soon as Batch 1 is done, in parallel with Batches 2-3 **if** the team has more than one contributor to swarm with; otherwise take it sequentially after Batch 3 to avoid splitting focus. | | |||
| | 5 | Show render progress and completion summary | Needs both Batch 3 (event format to parse) and Batch 4 (a running process to parse events from) — last in the chain regardless of team size. | | |||
| **Today's pull:** Batch 1 only, per the daily scrum above. | |||
| **Today's pull:** Batch 5, "Show render progress and completion summary," per the daily scrum above — the last batch in the sprint's execution order. | |||
| @@ -1,2 +1,4 @@ | |||
| bin/ | |||
| obj/ | |||
| .idea | |||
| *.DotSettings.user | |||
| @@ -23,13 +23,66 @@ usage errors (exit `2`). | |||
| ## stdout / stderr rules | |||
| - **stdout** is reserved for machine-readable output: today just `--help` text (human-readable | |||
| by exception, since it's explicitly for a human at a terminal); starting with the "Emit | |||
| machine-readable progress during render" story, line-oriented progress events land here too. | |||
| The desktop app should be able to treat stdout as parseable and never need to filter noise | |||
| out of it. | |||
| - **stdout** is reserved for machine-readable output: `--help` text (human-readable by exception, | |||
| since it's explicitly for a human at a terminal), and — once argument/path validation has | |||
| succeeded and rendering begins — one line-oriented `PROGRESS` event per line (see "Progress | |||
| events" below). The desktop app should be able to treat stdout as parseable and never need to | |||
| filter noise out of it. | |||
| - **stderr** carries every error, one per line, each prefixed `ERROR: `. Multiple problems (e.g. | |||
| two missing arguments) are each reported as their own line rather than stopping at the first. | |||
| A render-stage failure still writes its `ERROR: ` line(s) here exactly as before — the stdout | |||
| `failure` progress event described below supplements this, it never replaces it, and it never | |||
| changes the exit code. | |||
| ## Progress events | |||
| Once argument parsing, input-path validation, and output-path validation have all succeeded | |||
| (i.e. the CLI is past every exit-`2`/`3`/`4` case and has started the actual render), it emits | |||
| one line per event to stdout in this fixed, space-separated, `key=value` format: | |||
| ``` | |||
| PROGRESS <kind> elapsedMs=<integer> completed=<integer> [reason=<text to end of line>] | |||
| ``` | |||
| - `<kind>` is one of `startup`, `render`, `complete`, `failure`. | |||
| - `elapsedMs` — milliseconds since the render phase started, as an integer. Always present. | |||
| - `completed` — number of CSV records successfully rendered to a page so far. Always present | |||
| (`0` for `startup` and for a failure that happened before any page was rendered). | |||
| - `reason` — present only on `failure`; a short, human-readable description of what went wrong. | |||
| It is always the **last** field on the line and takes everything remaining on the line | |||
| verbatim (embedded newlines are stripped to a single space first), so it never needs quoting | |||
| or escaping and a consumer can safely `Split(' ', 5)`-style parse the fixed fields first. | |||
| Why this shape instead of one JSON object per line: it stays trivially parseable with a plain | |||
| string split (no JSON library dependency needed in the desktop shell), it stays readable at a | |||
| terminal or in a redirected log file — matching the plain-text style already used for stderr's | |||
| `ERROR: ` lines — and every numeric field is a culture-invariant integer, so there's no | |||
| decimal-separator ambiguity across locales. Implementation: | |||
| [`Progress/ProgressEventFormatter.cs`](src/EnvelopeRenderer.Cli/Progress/ProgressEventFormatter.cs). | |||
| Event kinds, each emitted at most as documented: | |||
| | Kind | When | Count per run | | |||
| |---|---|---| | |||
| | `startup` | Immediately when the render phase begins, before template parsing, CSV header validation, or Debenu setup. | Exactly 1 | | |||
| | `render` | After a record is successfully rendered to a page. **Throttled** to at least once per second — see below — not once per row. | 0 or more | | |||
| | `complete` | The run finished successfully; `completed` is the final record count. | Exactly 1, only on success | | |||
| | `failure` | The run failed at any point during the render phase (template parse, CSV read, Debenu setup, or the per-record render/save loop); `completed` is however many records were successfully rendered before the failure, and `reason` summarizes the same problem reported to stderr. | Exactly 1, only on failure | | |||
| A run therefore always starts with exactly one `startup` line and ends with exactly one | |||
| `complete` or `failure` line, with zero or more `render` lines in between. Argument parsing and | |||
| path-validation failures (exit `2`/`3`/`4`) produce **no** progress events at all — that failure | |||
| mode is entirely a pre-render validation error, unaffected by this contract. | |||
| ### Throttling | |||
| `render` events are throttled to at least once per second of wall-clock time, not once per CSV | |||
| row, so a fast render of thousands of rows doesn't flood stdout with one line per row. The first | |||
| `render` event after `startup` is always written immediately (so a slow run shows *something* | |||
| right away); after that, an event is only written once at least 1000ms have passed since the | |||
| last one that was actually written. `startup`, `complete`, and `failure` are never throttled — | |||
| each happens exactly once, so there's nothing to throttle. Implementation: | |||
| [`Progress/ConsoleProgressReporter.cs`](src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs). | |||
| ## Exit codes | |||
| @@ -91,11 +144,27 @@ $ dotnet run --project src/EnvelopeRenderer.Cli -- --template envelope.xml --csv | |||
| ERROR: Output directory does not exist: 'nosuchdir'. | |||
| exit=4 | |||
| $ dotnet run --project src/EnvelopeRenderer.Cli -- --template sample-data/sample-envelope-template.xml --csv sample-data/wilson.csv --output out.pdf | |||
| $ dotnet run --project src/EnvelopeRenderer.Cli -- --template sample-data/sample-envelope-template.xml --csv "sample-data/87700 - 999999 - Wilson Township.csv" --output out.pdf | |||
| # stdout: | |||
| PROGRESS startup elapsedMs=0 completed=0 | |||
| PROGRESS render elapsedMs=802 completed=1 | |||
| PROGRESS render elapsedMs=1804 completed=392 | |||
| PROGRESS failure elapsedMs=1805 completed=392 reason=Failed to save PDF to 'out.pdf' (error code 999). | |||
| # stderr: | |||
| ERROR: Failed to save PDF to 'out.pdf' (error code 999). | |||
| exit=1 | |||
| # (DEBENU_LICENSE_KEY not set — see "Debenu license key" above) | |||
| # (DEBENU_LICENSE_KEY not set — see "Debenu license key" above. Note the throttling in action: | |||
| # 392 records were each individually reported to RenderEngine, but only 2 `render` lines were | |||
| # actually written to stdout because the whole render took under 2 seconds.) | |||
| $ DEBENU_LICENSE_KEY="$(cat ../key.txt)" dotnet run --project src/EnvelopeRenderer.Cli -- --template sample-data/sample-envelope-template.xml --csv "sample-data/87700 - 999999 - Wilson Township.csv" --output out.pdf | |||
| exit=0 | |||
| # stdout (illustrative — not re-captured for this story since no license key was available in | |||
| # this dev environment; the render loop that produces `startup`/`render` is identical to the | |||
| # failure example above, a valid key only changes the final Save() outcome, so the only actual | |||
| # difference is the last line): | |||
| PROGRESS startup elapsedMs=0 completed=0 | |||
| PROGRESS render elapsedMs=802 completed=1 | |||
| PROGRESS render elapsedMs=1804 completed=392 | |||
| PROGRESS complete elapsedMs=<final> completed=392 | |||
| ``` | |||
| @@ -3,5 +3,8 @@ | |||
| <Project Path="src/EnvelopeRenderer.Cli.Tests/EnvelopeRenderer.Cli.Tests.csproj" /> | |||
| <Project Path="src/EnvelopeRenderer.Cli/EnvelopeRenderer.Cli.csproj" /> | |||
| <Project Path="src/EnvelopeRenderer.Debenu/EnvelopeRenderer.Debenu.csproj" /> | |||
| <Project Path="src/EnvelopeRenderer.Desktop.Core/EnvelopeRenderer.Desktop.Core.csproj" /> | |||
| <Project Path="src/EnvelopeRenderer.Desktop.Tests/EnvelopeRenderer.Desktop.Tests.csproj" /> | |||
| <Project Path="src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj" /> | |||
| </Folder> | |||
| </Solution> | |||
| @@ -9,6 +9,9 @@ This folder is where the actual project codebase lives, built incrementally by t | |||
| - `src/EnvelopeRenderer.Cli` — the render CLI. Argument/exit-code contract documented in [`CLI_CONTRACT.md`](CLI_CONTRACT.md); text-only template format in [`TEMPLATE_FORMAT.md`](TEMPLATE_FORMAT.md). | |||
| - `src/EnvelopeRenderer.Debenu` — isolates the vendor-generated Debenu interop wrapper (linked, unmodified) in its own project so its lack of nullable annotations doesn't leak warnings elsewhere. | |||
| - `src/EnvelopeRenderer.Cli.Tests` — xUnit tests for the CLI (chosen as the test framework in the absence of a project-wide pick; see `project_config.md`), including one integration test that renders through the real Debenu DLL. | |||
| - `src/EnvelopeRenderer.Desktop` — the WinForms operator shell. Picks template/CSV/output paths, launches `EnvelopeRenderer.Cli` as a child process against the contract in `CLI_CONTRACT.md`, and shows live progress plus a completion summary while it runs (Sprint 1, Batches 4-5). Contains only Form/UI wiring — see "Running the desktop app" below. | |||
| - `src/EnvelopeRenderer.Desktop.Core` — UI-independent logic behind the desktop shell (input validation, CLI argument construction, CLI executable discovery, process launch/lifetime tracking, `PROGRESS` stream parsing, and status/summary formatting). Plain `net10.0`, no WinForms dependency, so it's fully unit testable. | |||
| - `src/EnvelopeRenderer.Desktop.Tests` — xUnit tests for `EnvelopeRenderer.Desktop.Core`. | |||
| ``` | |||
| dotnet build EnvelopeRenderer.slnx | |||
| @@ -22,6 +25,42 @@ DEBENU_LICENSE_KEY="$(cat ../key.txt)" dotnet run --project src/EnvelopeRenderer | |||
| --output out.pdf | |||
| ``` | |||
| ## Running the desktop app | |||
| ``` | |||
| dotnet run --project src/EnvelopeRenderer.Desktop | |||
| ``` | |||
| `EnvelopeRenderer.Desktop.csproj` references `EnvelopeRenderer.Cli.csproj` as a project reference | |||
| purely so a normal `dotnet build`/`dotnet run` always produces a freshly built | |||
| `EnvelopeRenderer.Cli.exe` copied next to `EnvelopeRenderer.Desktop.exe` — the desktop app never | |||
| calls into the CLI's code directly, it only launches that `.exe` as a child process with | |||
| `--template`/`--csv`/`--output` set from the three pickers, per `CLI_CONTRACT.md`. If the CLI | |||
| executable has been moved or is deployed separately, point the desktop app at it with the | |||
| `ENVELOPERENDERER_CLI_PATH` environment variable (see | |||
| [`Launch/CliExecutablePathResolver.cs`](src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResolver.cs)). | |||
| The render runs in a hidden child process — the desktop app now reads its `PROGRESS`/`ERROR:` | |||
| stdout/stderr streams itself (Sprint 1, Batch 5: "Show render progress and completion summary") | |||
| rather than leaving a separate visible console window, per `CLI_CONTRACT.md`. While a render is | |||
| running, the status label shows live, non-technical updates (e.g. "128 record(s) processed, 2.1s | |||
| elapsed..."); once the process exits, it shows a completion summary — success totals and elapsed | |||
| time on exit `0`, or the failure reason (from the CLI's `failure` progress event, falling back to | |||
| its stderr `ERROR:` lines for a pre-render validation failure that never reached the render loop) | |||
| on any other exit code. The Render button stays disabled for the whole run and is only | |||
| re-enabled once the process actually exits, not the moment it starts, so an operator can't launch | |||
| a second render against the same output while one is still in flight. A *launch* failure (the CLI | |||
| executable itself couldn't be started, e.g. missing or moved) is still reported via an inline | |||
| status message and a message box, distinct from a *render* failure. | |||
| Note: the CLI/render pipeline does not currently expose a "warning" concept anywhere (e.g. no | |||
| missing-image placeholder handling exists yet — that belongs to a not-yet-built epic, | |||
| `backlog/epics/07_dynamic_and_network_image_handling.md`, for a template format that doesn't | |||
| support images in this text-only slice). The completion summary therefore reports only the | |||
| fields the CLI actually exposes today (elapsed time, records completed, success/failure) and | |||
| omits a warning count rather than fabricate one — see | |||
| [`Launch/RenderCompletionSummaryFormatter.cs`](src/EnvelopeRenderer.Desktop.Core/Launch/RenderCompletionSummaryFormatter.cs). | |||
| ## Seed assets | |||
| - Sample CSV data lives in `code/sample-data/87700 - 999999 - Wilson Township.csv`. | |||
| @@ -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 EnvelopeRenderer.Cli.Progress; | |||
| using EnvelopeRenderer.Cli.Render; | |||
| namespace EnvelopeRenderer.Cli.Tests; | |||
| @@ -13,6 +14,20 @@ namespace EnvelopeRenderer.Cli.Tests; | |||
| /// </summary> | |||
| public class DebenuPdfRendererIntegrationTests | |||
| { | |||
| /// <summary>Records every <see cref="IProgressReporter.ReportRenderProgress"/> call | |||
| /// unconditionally (no throttling — that behavior belongs to | |||
| /// <see cref="EnvelopeRenderer.Cli.Progress.ConsoleProgressReporter"/> and is covered by its | |||
| /// own unit tests), so this test can assert RenderEngine calls it once per real record | |||
| /// against the actual Debenu render path without depending on wall-clock timing.</summary> | |||
| private sealed class RecordingProgressReporter : IProgressReporter | |||
| { | |||
| public List<int> RenderProgressCalls { get; } = new(); | |||
| public void Startup() { } | |||
| public void ReportRenderProgress(int completed) => RenderProgressCalls.Add(completed); | |||
| public void Complete(int completed) { } | |||
| public void Failure(int completed, string reason) { } | |||
| } | |||
| private static string? FindRepoCodeRoot() | |||
| { | |||
| var directory = new DirectoryInfo(AppContext.BaseDirectory); | |||
| @@ -60,15 +75,17 @@ public class DebenuPdfRendererIntegrationTests | |||
| Assert.True(created, createError); | |||
| var outputPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.pdf"); | |||
| var progress = new RecordingProgressReporter(); | |||
| try | |||
| { | |||
| using (renderer) | |||
| { | |||
| var result = RenderEngine.Render( | |||
| templateResult.Document!, headers, csv.ReadRecords(), renderer!, outputPath); | |||
| templateResult.Document!, headers, csv.ReadRecords(), renderer!, outputPath, progress); | |||
| Assert.True(result.Succeeded, string.Join("; ", result.Errors)); | |||
| Assert.Equal(expectedRecordCount, result.RecordsRendered); | |||
| Assert.Equal(Enumerable.Range(1, expectedRecordCount), progress.RenderProgressCalls); | |||
| } | |||
| Assert.True(File.Exists(outputPath)); | |||
| @@ -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; | |||
| namespace EnvelopeRenderer.Cli.Tests; | |||
| public class RenderEngineTests | |||
| { | |||
| private sealed class SpyProgressReporter : IProgressReporter | |||
| { | |||
| public List<int> RenderProgressCalls { get; } = new(); | |||
| public bool StartupCalled { get; private set; } | |||
| public int? CompletedCount { get; private set; } | |||
| public (int Completed, string Reason)? FailureCall { get; private set; } | |||
| public void Startup() => StartupCalled = true; | |||
| public void ReportRenderProgress(int completed) => RenderProgressCalls.Add(completed); | |||
| public void Complete(int completed) => CompletedCount = completed; | |||
| public void Failure(int completed, string reason) => FailureCall = (completed, reason); | |||
| } | |||
| private sealed class FakePdfRenderer : IPdfRenderer | |||
| { | |||
| public List<(double Width, double Height, IReadOnlyList<TextDraw> Draws)> Pages { get; } = new(); | |||
| @@ -129,4 +143,48 @@ public class RenderEngineTests | |||
| Assert.True(result.Succeeded); | |||
| } | |||
| [Fact] | |||
| public void Render_ReportsProgressAfterEveryRecord_WithRunningTotal() | |||
| { | |||
| var renderer = new FakePdfRenderer(); | |||
| var progress = new SpyProgressReporter(); | |||
| var result = RenderEngine.Render(Template, new[] { "Full Name" }, Records, renderer, "out.pdf", progress); | |||
| Assert.True(result.Succeeded); | |||
| Assert.Equal(new[] { 1, 2 }, progress.RenderProgressCalls); | |||
| } | |||
| [Fact] | |||
| public void Render_WithoutProgressReporter_StillSucceeds() | |||
| { | |||
| var renderer = new FakePdfRenderer(); | |||
| var result = RenderEngine.Render(Template, new[] { "Full Name" }, Records, renderer, "out.pdf"); | |||
| Assert.True(result.Succeeded); | |||
| } | |||
| [Fact] | |||
| public void Render_UnknownColumnReference_NeverReportsProgress() | |||
| { | |||
| var renderer = new FakePdfRenderer(); | |||
| var progress = new SpyProgressReporter(); | |||
| RenderEngine.Render(Template, new[] { "Some Other Column" }, Records, renderer, "out.pdf", progress); | |||
| Assert.Empty(progress.RenderProgressCalls); | |||
| } | |||
| [Fact] | |||
| public void Render_PageFailure_OnlyReportsProgressForRecordsThatSucceededBeforeTheFailure() | |||
| { | |||
| var renderer = new FakePdfRenderer { FailOnPage = true }; | |||
| var progress = new SpyProgressReporter(); | |||
| RenderEngine.Render(Template, new[] { "Full Name" }, Records, renderer, "out.pdf", progress); | |||
| Assert.Empty(progress.RenderProgressCalls); | |||
| } | |||
| } | |||
| @@ -1,4 +1,5 @@ | |||
| using EnvelopeRenderer.Cli.Cli; | |||
| using EnvelopeRenderer.Cli.Progress; | |||
| using EnvelopeRenderer.Cli.Render; | |||
| const string Usage = """ | |||
| @@ -48,10 +49,14 @@ switch (result.Kind) | |||
| static int RunRender(CliArguments args) | |||
| { | |||
| var progress = ConsoleProgressReporter.CreateDefault(Console.Out); | |||
| progress.Startup(); | |||
| var templateResult = TemplateXmlParser.Parse(args.TemplatePath); | |||
| if (!templateResult.Succeeded) | |||
| { | |||
| WriteErrors(templateResult.Errors); | |||
| progress.Failure(0, string.Join("; ", templateResult.Errors)); | |||
| return ExitCodes.UnexpectedError; | |||
| } | |||
| @@ -63,7 +68,9 @@ static int RunRender(CliArguments args) | |||
| } | |||
| catch (Exception ex) | |||
| { | |||
| Console.Error.WriteLine($"ERROR: Failed to read CSV headers from '{args.CsvPath}': {ex.Message}"); | |||
| var message = $"Failed to read CSV headers from '{args.CsvPath}': {ex.Message}"; | |||
| Console.Error.WriteLine($"ERROR: {message}"); | |||
| progress.Failure(0, message); | |||
| return ExitCodes.UnexpectedError; | |||
| } | |||
| @@ -75,19 +82,23 @@ static int RunRender(CliArguments args) | |||
| if (!DebenuPdfRenderer.TryCreate(dllPath, licenseKey, out var renderer, out var createError)) | |||
| { | |||
| Console.Error.WriteLine($"ERROR: {createError}"); | |||
| progress.Failure(0, createError!); | |||
| return ExitCodes.UnexpectedError; | |||
| } | |||
| using (renderer) | |||
| { | |||
| var renderResult = RenderEngine.Render( | |||
| templateResult.Document!, headers, csv.ReadRecords(), renderer!, args.OutputPath); | |||
| templateResult.Document!, headers, csv.ReadRecords(), renderer!, args.OutputPath, progress); | |||
| if (!renderResult.Succeeded) | |||
| { | |||
| WriteErrors(renderResult.Errors); | |||
| progress.Failure(renderResult.RecordsRendered, string.Join("; ", renderResult.Errors)); | |||
| return ExitCodes.UnexpectedError; | |||
| } | |||
| progress.Complete(renderResult.RecordsRendered); | |||
| } | |||
| return ExitCodes.Success; | |||
| @@ -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; | |||
| /// <summary> | |||
| @@ -12,7 +14,8 @@ public static class RenderEngine | |||
| IReadOnlyList<string> csvHeaders, | |||
| IEnumerable<IReadOnlyDictionary<string, string>> records, | |||
| IPdfRenderer renderer, | |||
| string outputPath) | |||
| string outputPath, | |||
| IProgressReporter? progress = null) | |||
| { | |||
| var unknownColumns = template.Elements | |||
| .Where(e => e.IsDynamic) | |||
| @@ -47,6 +50,7 @@ public static class RenderEngine | |||
| } | |||
| recordCount++; | |||
| progress?.ReportRenderProgress(recordCount); | |||
| } | |||
| if (recordCount == 0) | |||
| @@ -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 | `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 | |||
| - **Primary user / vision:** Non-technical print operators can visually design envelope layouts, map CSV data, preview records, and generate print-ready PDFs without developer intervention. | |||
| - **Tech stack:** Native Windows desktop application using `C#` on `.NET 10`; desktop UI framework still to be confirmed; fixed integration points are Debenu Quick PDF Library `10.13` (64-bit DLL), XML-based templates, CSV input with header rows, and support for local plus UNC file paths. CSV parsing uses `CsvHelper` (chosen by `dev-team` during Sprint 1, Batch 2 — the real sample data has quoted commas in address fields, which a naive split would break). Rendering requires a Debenu license key at runtime (`DEBENU_LICENSE_KEY`); see `code/CLI_CONTRACT.md`. | |||
| - **Tech stack:** Native Windows desktop application using `C#` on `.NET 10`; desktop UI framework is `WinForms` (confirmed by the user ahead of Sprint 1, Batch 4 — simplest fit for file pickers and a launch action, matches the team's scaffolding pace). Fixed integration points are Debenu Quick PDF Library `10.13` (64-bit DLL), XML-based templates, CSV input with header rows, and support for local plus UNC file paths. CSV parsing uses `CsvHelper` (chosen by `dev-team` during Sprint 1, Batch 2 — the real sample data has quoted commas in address fields, which a naive split would break). Rendering requires a Debenu license key at runtime (`DEBENU_LICENSE_KEY`); see `code/CLI_CONTRACT.md`. | |||
| - **Test framework(s):** `xUnit` (chosen by `dev-team` during Sprint 1, Batch 1 — the standard default for .NET SDK-style projects, no reason to deviate). Minimum validation baseline from the requirements is self-tested sample CSV/render scenarios, including overflow, missing-font, missing-image, and high-volume render checks. | |||
| - **Code review process:** Formal reviewer is not defined in the requirements; until a team process is set, changes require self-review against acceptance criteria and Definition of Done before being considered complete. | |||
| - **CI pipeline:** No CI pipeline is defined in the requirements; initial expectation is local build and verification gates, with CI setup to be planned as part of early delivery work. | |||
| @@ -2,16 +2,16 @@ | |||
| > The single live "where are we right now" file. Read this FIRST at the start of any session that touches this repo - don't infer phase or sprint from conversation history. Updated LAST by whichever agent completes the current step, as documented in `AGENTS.md` under "Automated State-Driven Handoff." | |||
| **Phase:** 3 - Sprint execution | |||
| **Leading agent:** `dev-team` | |||
| **Process file:** `process/03_sprint_execution.md` | |||
| **Phase:** 4 - Sprint review | |||
| **Leading agent:** `product-owner` | |||
| **Process file:** `process/04_sprint_review.md` | |||
| **Sprint:** 1 | |||
| **Sprint dates:** 2026-09-08 - 2026-09-11 | |||
| **Sprint goal:** Prove a thin text-only desktop-to-CLI rendering spine so an operator can select files, launch a job, watch progress, and receive a PDF generated through Debenu from a known XML template. | |||
| **Current sprint backlog:** `backlog/sprints/sprint-1.md` | |||
| **Next action:** Batches 1 and 2 are Done. The CLI now renders real text-only PDFs through Debenu Quick PDF 10.13 (`code/CLI_CONTRACT.md`, `code/TEMPLATE_FORMAT.md`), verified end-to-end against the 392-row sample CSV (32 tests passing, including one real-DLL integration test). Rendering requires `DEBENU_LICENSE_KEY` at runtime — see `code/CLI_CONTRACT.md`. `dev-team` pulls Batch 3 ("Emit machine-readable progress during render") next. The template-path and UNC-timeout impediments in `logs/impediment_log.md` are still open and unaffected by this batch. | |||
| **Next action:** All 5 committed Sprint 1 items are Done (exit criteria for Phase 3 met — see `backlog/sprints/sprint-1.md`). The walking skeleton is real end-to-end: an operator picks template/CSV/output paths in the WinForms desktop app (`EnvelopeRenderer.Desktop`), launches a render without freezing the UI, watches live progress parsed from the CLI's `PROGRESS <kind> ...` stdout stream, and gets a completion summary — verified with a real success run (392/392 pages, valid PDF) and a real forced-failure run against the actual sample CSV. 106 tests passing. `product-owner` runs Sprint Review next per `process/04_sprint_review.md`. Carry-forward items to raise at review: template-path and UNC-timeout impediments still open (`logs/impediment_log.md`); the 100k-record/300 DPI benchmark hasn't been re-run since Batch 2; no "warning" concept exists in the product yet, so the "warning count" acceptance task for the last story was satisfied by its documented absence rather than a real counter — product owner may want to scope that once image/overflow handling exists. | |||
| ## Phase reference | |||
| @@ -37,3 +37,4 @@ After phase 5, loop back to phase 1 for the next sprint. | |||
| | 2026-09-04 | 1 - Backlog refinement | Refined the top epics into sprint-ready stories, confirmed `C#/.NET 10` as the application stack, and reordered the backlog for the first text-only MVP slice. | | |||
| | 2026-09-04 | 2 - Sprint planning | Created Sprint 1 around a conservative desktop-to-CLI text-only rendering spine and recorded the committed backlog in `backlog/sprints/sprint-1.md`. | | |||
| | 2026-09-08 | 3 - Sprint execution (in progress) | Held Daily Scrum #1 and turned the sprint backlog into a dependency-ordered execution plan (5 batches) in `backlog/sprints/sprint-1.md`. Still in phase 3 — not a phase transition, just the day's checkpoint. | | |||
| | 2026-09-08 | 3 - Sprint execution | All 5 batches Done: CLI render contract, real Debenu text-only rendering, stdout progress events, WinForms desktop launch shell (user-confirmed as the UI framework), and live progress/completion display. 106 tests passing end-to-end. Sprint goal met with real verified runs against the 392-row sample CSV. | | |||
Powered by TurnKey Linux.