diff --git a/backlog/sprints/sprint-1.md b/backlog/sprints/sprint-1.md index 7247ac9..0a97be4 100644 --- a/backlog/sprints/sprint-1.md +++ b/backlog/sprints/sprint-1.md @@ -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.
- [x] Scaffold the CLI entrypoint and argument parsing for template, CSV, and output paths.
- [x] Validate missing or invalid required arguments for local and UNC path inputs.
- [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.
- [x] Parse the supported text-only XML template elements needed for page and text placement.
- [x] Load CSV rows and merge static plus dynamic text into the render model.
- [x] Generate PDF `1.4+` RGB output and write it to the requested output path.
- [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.
- [ ] Emit startup, active-render, completion, and failure progress events without corrupting error output.
- [ ] 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.
- [ ] Validate required operator inputs before the render starts.
- [ ] 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.
- [ ] Display active status updates whenever progress events arrive.
- [ ] 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.
- [x] Emit startup, active-render, completion, and failure progress events without corrupting error output.
- [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.
- [x] Validate required operator inputs before the render starts.
- [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.
- [x] Display active status updates whenever progress events arrive.
- [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 elapsedMs= completed= [reason=]` 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 ...` stdout events live (background async reads, `Progress` 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. diff --git a/code/.gitignore b/code/.gitignore index cd42ee3..47156ed 100644 --- a/code/.gitignore +++ b/code/.gitignore @@ -1,2 +1,4 @@ bin/ obj/ +.idea +*.DotSettings.user \ No newline at end of file diff --git a/code/CLI_CONTRACT.md b/code/CLI_CONTRACT.md index 7276d0b..86076cd 100644 --- a/code/CLI_CONTRACT.md +++ b/code/CLI_CONTRACT.md @@ -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 elapsedMs= completed= [reason=] +``` + +- `` 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= completed=392 ``` diff --git a/code/EnvelopeRenderer.slnx b/code/EnvelopeRenderer.slnx index 37e8405..0002eb9 100644 --- a/code/EnvelopeRenderer.slnx +++ b/code/EnvelopeRenderer.slnx @@ -3,5 +3,8 @@ + + + diff --git a/code/README.md b/code/README.md index b18a217..47f32c4 100644 --- a/code/README.md +++ b/code/README.md @@ -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`. diff --git a/code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs new file mode 100644 index 0000000..3fc5bea --- /dev/null +++ b/code/src/EnvelopeRenderer.Cli.Tests/ConsoleProgressReporterTests.cs @@ -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]); + } +} diff --git a/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererIntegrationTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererIntegrationTests.cs index b4bf5ef..47bca45 100644 --- a/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererIntegrationTests.cs +++ b/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererIntegrationTests.cs @@ -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; /// public class DebenuPdfRendererIntegrationTests { + /// Records every call + /// unconditionally (no throttling — that behavior belongs to + /// 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. + private sealed class RecordingProgressReporter : IProgressReporter + { + public List 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)); diff --git a/code/src/EnvelopeRenderer.Cli.Tests/ProgressEventFormatterTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/ProgressEventFormatterTests.cs new file mode 100644 index 0000000..5c0c857 --- /dev/null +++ b/code/src/EnvelopeRenderer.Cli.Tests/ProgressEventFormatterTests.cs @@ -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); + } +} diff --git a/code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs index 58091e7..a4ff500 100644 --- a/code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs +++ b/code/src/EnvelopeRenderer.Cli.Tests/RenderEngineTests.cs @@ -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 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 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); + } } diff --git a/code/src/EnvelopeRenderer.Cli/Program.cs b/code/src/EnvelopeRenderer.Cli/Program.cs index 0e80904..262c5b4 100644 --- a/code/src/EnvelopeRenderer.Cli/Program.cs +++ b/code/src/EnvelopeRenderer.Cli/Program.cs @@ -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; diff --git a/code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs b/code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs new file mode 100644 index 0000000..4f620fc --- /dev/null +++ b/code/src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; + +namespace EnvelopeRenderer.Cli.Progress; + +/// +/// Writes PROGRESS lines to the given (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 directly) so tests can drive throttling +/// deterministically without real `Thread.Sleep` calls; wires up a +/// real stopwatch for production use. +/// +public sealed class ConsoleProgressReporter : IProgressReporter +{ + private readonly TextWriter _output; + private readonly Func _elapsedMillisecondsProvider; + private readonly long _minIntervalMs; + private long? _lastEmittedAtMs; + + public ConsoleProgressReporter( + TextWriter output, Func elapsedMillisecondsProvider, long minIntervalMs = 1000) + { + _output = output; + _elapsedMillisecondsProvider = elapsedMillisecondsProvider; + _minIntervalMs = minIntervalMs; + } + + /// Production factory: throttles against a real, freshly-started stopwatch. + 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)); + } +} diff --git a/code/src/EnvelopeRenderer.Cli/Progress/IProgressReporter.cs b/code/src/EnvelopeRenderer.Cli/Progress/IProgressReporter.cs new file mode 100644 index 0000000..af5e42d --- /dev/null +++ b/code/src/EnvelopeRenderer.Cli/Progress/IProgressReporter.cs @@ -0,0 +1,29 @@ +namespace EnvelopeRenderer.Cli.Progress; + +/// +/// 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 +/// 's merge loop (and its unit tests) don't +/// depend on real console I/O or real wall-clock timing. +/// +public interface IProgressReporter +{ + /// Emitted exactly once, right before rendering begins (template parsing, CSV + /// header validation, and Debenu setup all happen after this). + void Startup(); + + /// Called after every successfully rendered record with the running total. An + /// implementation is free to not actually write a line for every call — see + /// 's throttling — but every call must be safe and + /// cheap since RenderEngine calls it once per CSV row. + void ReportRenderProgress(int completed); + + /// Emitted exactly once, on a successful run, and always written immediately + /// (never throttled/dropped). + void Complete(int completed); + + /// 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. + void Failure(int completed, string reason); +} diff --git a/code/src/EnvelopeRenderer.Cli/Progress/ProgressEventFormatter.cs b/code/src/EnvelopeRenderer.Cli/Progress/ProgressEventFormatter.cs new file mode 100644 index 0000000..377e4a7 --- /dev/null +++ b/code/src/EnvelopeRenderer.Cli/Progress/ProgressEventFormatter.cs @@ -0,0 +1,29 @@ +namespace EnvelopeRenderer.Cli.Progress; + +/// +/// 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. +/// +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; + } +} diff --git a/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs b/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs index 749df39..150d2d4 100644 --- a/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs +++ b/code/src/EnvelopeRenderer.Cli/Render/RenderEngine.cs @@ -1,3 +1,5 @@ +using EnvelopeRenderer.Cli.Progress; + namespace EnvelopeRenderer.Cli.Render; /// @@ -12,7 +14,8 @@ public static class RenderEngine IReadOnlyList csvHeaders, IEnumerable> 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) diff --git a/code/src/EnvelopeRenderer.Desktop.Core/EnvelopeRenderer.Desktop.Core.csproj b/code/src/EnvelopeRenderer.Desktop.Core/EnvelopeRenderer.Desktop.Core.csproj new file mode 100644 index 0000000..8dddcb2 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/EnvelopeRenderer.Desktop.Core.csproj @@ -0,0 +1,17 @@ + + + + + net10.0 + enable + enable + + + diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliArgumentListBuilder.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliArgumentListBuilder.cs new file mode 100644 index 0000000..fcdaa8d --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliArgumentListBuilder.cs @@ -0,0 +1,26 @@ +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// +/// Builds the CLI argument list from validated , 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 ) rather than a +/// single pre-quoted string, so paths containing spaces need no manual quoting/escaping here. +/// +public static class CliArgumentListBuilder +{ + /// + /// Builds the argument list. Callers must run first — + /// this does not re-check for blank values. + /// + public static IReadOnlyList Build(RenderLaunchInputs inputs) + { + return new[] + { + "--template", inputs.TemplatePath!, + "--csv", inputs.CsvPath!, + "--output", inputs.OutputPath!, + }; + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResolver.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResolver.cs new file mode 100644 index 0000000..4bc74e7 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResolver.cs @@ -0,0 +1,49 @@ +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// +/// 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 environment variable, if set — lets an +/// operator/installer point at a specific build without rebuilding the desktop app. +/// 2. 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. +/// +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 getEnvironmentVariable, + Func 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."); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResult.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResult.cs new file mode 100644 index 0000000..fb8a245 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliExecutablePathResult.cs @@ -0,0 +1,9 @@ +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// Result of trying to locate the EnvelopeRenderer.Cli executable to launch. +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); +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessLauncher.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessLauncher.cs new file mode 100644 index 0000000..9849aab --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessLauncher.cs @@ -0,0 +1,106 @@ +using System.ComponentModel; +using System.Diagnostics; + +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// +/// 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. +/// +public sealed class CliProcessLauncher +{ + private readonly Func _startProcess; + + /// + /// Test seam: given a , starts a process and returns a handle + /// to it. Defaults to actually starting a real . + /// + public CliProcessLauncher(Func? startProcess = null) + { + _startProcess = startProcess ?? DefaultStartProcess; + } + + private static ICliProcess DefaultStartProcess(ProcessStartInfo startInfo) + { + var process = new Process { StartInfo = startInfo }; + process.Start(); + return new RealCliProcess(process); + } + + /// + /// 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 + /// as it arrives — construct it as a + /// on the UI thread so updates are automatically marshalled back to it. + /// + public Task RunAsync( + string executablePath, IReadOnlyList arguments, IProgress? onProgress = null) => + Task.Run(() => Run(executablePath, arguments, onProgress)); + + /// Synchronous run, exposed directly for unit testing without threading noise. + public RenderRunResult Run( + string executablePath, IReadOnlyList arguments, IProgress? 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(); + + // 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 onLine) + { + string? line; + while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) is not null) + { + onLine(line); + } + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessStartInfoFactory.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessStartInfoFactory.cs new file mode 100644 index 0000000..dee4e8d --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/CliProcessStartInfoFactory.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; + +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// +/// Builds the 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 ) and displays them inside the desktop shell, so a +/// separate visible console window is no longer needed for the operator to see progress. +/// +public static class CliProcessStartInfoFactory +{ + public static ProcessStartInfo Create(string executablePath, IReadOnlyList 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; + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/ElapsedTimeFormatter.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ElapsedTimeFormatter.cs new file mode 100644 index 0000000..dbbe6c7 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ElapsedTimeFormatter.cs @@ -0,0 +1,10 @@ +using System.Globalization; + +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// Formats a millisecond duration from a `PROGRESS` event as short, human-friendly text. +public static class ElapsedTimeFormatter +{ + public static string Format(int elapsedMs) => + (elapsedMs / 1000.0).ToString("0.0", CultureInfo.InvariantCulture) + "s"; +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/ICliProcess.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ICliProcess.cs new file mode 100644 index 0000000..f38372e --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ICliProcess.cs @@ -0,0 +1,17 @@ +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// +/// Minimal abstraction over a started child process: just enough for +/// to stream its stdout/stderr and learn its exit code, without +/// tying 's tests to a real OS process. Production code gets +/// ; tests inject an in-memory fake backed by s. +/// +public interface ICliProcess : IDisposable +{ + TextReader StandardOutput { get; } + + TextReader StandardError { get; } + + /// Waits for the process to exit and returns its exit code. + Task WaitForExitAsync(); +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEvent.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEvent.cs new file mode 100644 index 0000000..556ad85 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEvent.cs @@ -0,0 +1,8 @@ +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// +/// A single parsed `PROGRESS <kind> elapsedMs=<n> completed=<n> [reason=<text>]` +/// line from the CLI's stdout, per ../../../CLI_CONTRACT.md. is only ever +/// populated for . +/// +public sealed record ProgressEvent(ProgressEventKind Kind, int ElapsedMs, int Completed, string? Reason); diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventKind.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventKind.cs new file mode 100644 index 0000000..835af2a --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventKind.cs @@ -0,0 +1,12 @@ +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// +/// Mirrors the `PROGRESS <kind> ...` event kinds documented in ../../../CLI_CONTRACT.md. +/// +public enum ProgressEventKind +{ + Startup, + Render, + Complete, + Failure, +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventParser.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventParser.cs new file mode 100644 index 0000000..d8a1cbf --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/ProgressEventParser.cs @@ -0,0 +1,99 @@ +using System.Globalization; + +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// +/// 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. +/// +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); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RealCliProcess.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RealCliProcess.cs new file mode 100644 index 0000000..ca54b64 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RealCliProcess.cs @@ -0,0 +1,26 @@ +using System.Diagnostics; + +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// Wraps a real, already-started as an . +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 WaitForExitAsync() + { + await _process.WaitForExitAsync().ConfigureAwait(false); + return _process.ExitCode; + } + + public void Dispose() => _process.Dispose(); +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderCompletionSummaryFormatter.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderCompletionSummaryFormatter.cs new file mode 100644 index 0000000..3ac1698 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderCompletionSummaryFormatter.cs @@ -0,0 +1,55 @@ +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// +/// Formats the final outcome of a render run (see ) 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. +/// +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."; + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchInputs.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchInputs.cs new file mode 100644 index 0000000..4ca8e94 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchInputs.cs @@ -0,0 +1,8 @@ +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// +/// 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). +/// +public sealed record RenderLaunchInputs(string? TemplatePath, string? CsvPath, string? OutputPath); diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidationResult.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidationResult.cs new file mode 100644 index 0000000..ef9fe82 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidationResult.cs @@ -0,0 +1,9 @@ +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// Result of validating before allowing a launch. +public sealed record RenderLaunchValidationResult(bool IsValid, IReadOnlyList Errors) +{ + public static readonly RenderLaunchValidationResult Valid = new(true, Array.Empty()); + + public static RenderLaunchValidationResult Invalid(IReadOnlyList errors) => new(false, errors); +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidator.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidator.cs new file mode 100644 index 0000000..e0b0a42 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderLaunchValidator.cs @@ -0,0 +1,35 @@ +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// +/// 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. +/// +public static class RenderLaunchValidator +{ + public static RenderLaunchValidationResult Validate(RenderLaunchInputs inputs) + { + var errors = new List(); + + 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); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderProgressStatusFormatter.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderProgressStatusFormatter.cs new file mode 100644 index 0000000..993f692 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderProgressStatusFormatter.cs @@ -0,0 +1,23 @@ +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// +/// 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 's progress +/// callback, including the terminal `complete`/`failure` events as they stream by — the final +/// completion summary (see ) 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. +/// +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, + }; +} diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderRunResult.cs b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderRunResult.cs new file mode 100644 index 0000000..f8ce7de --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Core/Launch/RenderRunResult.cs @@ -0,0 +1,26 @@ +namespace EnvelopeRenderer.Desktop.Core.Launch; + +/// +/// The full outcome of launching the CLI and running it to completion. Distinguishes a *launch* +/// failure ( is false — the process never started at all, see +/// ) from a *render* outcome (the process started, ran, and +/// exited — see and , the last successfully +/// parsed `PROGRESS` line). Per ../../../CLI_CONTRACT.md, 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 null +/// and is the only source of detail. +/// +public sealed record RenderRunResult( + bool Started, + string? StartFailureMessage, + int? ExitCode, + ProgressEvent? FinalProgress, + IReadOnlyList StdErrLines) +{ + /// True only when the process started and exited with code 0. + public bool Succeeded => Started && ExitCode == 0; + + public static RenderRunResult StartFailure(string message) => + new(Started: false, StartFailureMessage: message, ExitCode: null, FinalProgress: null, StdErrLines: Array.Empty()); +} diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/CliArgumentListBuilderTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/CliArgumentListBuilderTests.cs new file mode 100644 index 0000000..37583ee --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/CliArgumentListBuilderTests.cs @@ -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]); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/CliExecutablePathResolverTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/CliExecutablePathResolverTests.cs new file mode 100644 index 0000000..56fe42b --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/CliExecutablePathResolverTests.cs @@ -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); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/CliProcessLauncherTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/CliProcessLauncherTests.cs new file mode 100644 index 0000000..c3fbbba --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/CliProcessLauncherTests.cs @@ -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" }; + + /// In-memory fake backed by s. + private sealed class FakeCliProcess : ICliProcess + { + private readonly int _exitCode; + + public FakeCliProcess(IEnumerable stdOutLines, IEnumerable 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 WaitForExitAsync() => Task.FromResult(_exitCode); + + public void Dispose() + { + StandardOutput.Dispose(); + StandardError.Dispose(); + } + } + + /// + /// A fake whose blocks (on a background thread, so it never + /// deadlocks the caller) until the test releases it — used to prove + /// hands the whole run off to a background thread instead of blocking the caller. + /// + 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 WaitForExitAsync() => Task.Run(() => + { + _entered.Set(); + _release.Wait(TimeSpan.FromSeconds(5)); + return 0; + }); + + public void Dispose() + { + StandardOutput.Dispose(); + StandardError.Dispose(); + } + } + + /// Test double that reports synchronously, unlike the real (which posts + /// to a captured SynchronizationContext and may run the callback asynchronously) — needed so tests can + /// assert on reported events immediately after a synchronous call. + private sealed class SynchronousProgress : IProgress + { + private readonly Action _callback; + + public SynchronousProgress(Action 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(), 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(), exitCode: 0)); + var reported = new List(); + + launcher.Run( + @"C:\App\EnvelopeRenderer.Cli.exe", + Arguments, + onProgress: new SynchronousProgress(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(), 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(), 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(), 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); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/CliProcessStartInfoFactoryTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/CliProcessStartInfoFactoryTests.cs new file mode 100644 index 0000000..2c442cb --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/CliProcessStartInfoFactoryTests.cs @@ -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()); + + Assert.True(startInfo.RedirectStandardOutput); + Assert.True(startInfo.RedirectStandardError); + Assert.True(startInfo.CreateNoWindow); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/EnvelopeRenderer.Desktop.Tests.csproj b/code/src/EnvelopeRenderer.Desktop.Tests/EnvelopeRenderer.Desktop.Tests.csproj new file mode 100644 index 0000000..698357f --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/EnvelopeRenderer.Desktop.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/ProgressEventParserTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/ProgressEventParserTests.cs new file mode 100644 index 0000000..752abed --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/ProgressEventParserTests.cs @@ -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); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/RenderCompletionSummaryFormatterTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/RenderCompletionSummaryFormatterTests.cs new file mode 100644 index 0000000..a5887b3 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/RenderCompletionSummaryFormatterTests.cs @@ -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()); + + 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()); + + 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()); + + var failure = new ProgressEvent(ProgressEventKind.Failure, 1805, 392, "boom"); + var failureResult = new RenderRunResult(true, null, 1, failure, Array.Empty()); + + Assert.DoesNotContain("warning", RenderCompletionSummaryFormatter.Format(successResult), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("warning", RenderCompletionSummaryFormatter.Format(failureResult), StringComparison.OrdinalIgnoreCase); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/RenderLaunchValidatorTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/RenderLaunchValidatorTests.cs new file mode 100644 index 0000000..78c827b --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/RenderLaunchValidatorTests.cs @@ -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); + }); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/RenderProgressStatusFormatterTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/RenderProgressStatusFormatterTests.cs new file mode 100644 index 0000000..5d4e288 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop.Tests/RenderProgressStatusFormatterTests.cs @@ -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); + } +} diff --git a/code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj b/code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj new file mode 100644 index 0000000..33ad457 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop/EnvelopeRenderer.Desktop.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0-windows + true + enable + enable + + + + + + + + + + diff --git a/code/src/EnvelopeRenderer.Desktop/MainForm.cs b/code/src/EnvelopeRenderer.Desktop/MainForm.cs new file mode 100644 index 0000000..8f3b7b1 --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop/MainForm.cs @@ -0,0 +1,213 @@ +using System.Drawing; +using System.Windows.Forms; +using EnvelopeRenderer.Desktop.Core.Launch; + +namespace EnvelopeRenderer.Desktop; + +/// +/// 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. +/// +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()) + { + } + + /// Internal constructor allowing a launcher to be injected (used by tests/composition). + 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 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(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; + } +} diff --git a/code/src/EnvelopeRenderer.Desktop/Program.cs b/code/src/EnvelopeRenderer.Desktop/Program.cs new file mode 100644 index 0000000..d86b85c --- /dev/null +++ b/code/src/EnvelopeRenderer.Desktop/Program.cs @@ -0,0 +1,12 @@ +namespace EnvelopeRenderer.Desktop; + +internal static class Program +{ + /// The main entry point for the desktop application. + [STAThread] + private static void Main() + { + ApplicationConfiguration.Initialize(); + Application.Run(new MainForm()); + } +} diff --git a/logs/technical_debt_log.md b/logs/technical_debt_log.md index 426857e..a8e9123 100644 --- a/logs/technical_debt_log.md +++ b/logs/technical_debt_log.md @@ -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). | diff --git a/project_config.md b/project_config.md index 7857cfd..28dcb2b 100644 --- a/project_config.md +++ b/project_config.md @@ -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. diff --git a/state.md b/state.md index 934c9cf..cb501d2 100644 --- a/state.md +++ b/state.md @@ -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 ...` 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. |