diff --git a/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs b/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs index 9668a96..5c891dd 100644 --- a/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs +++ b/JobTrackerApi.Tests/CoverLetterDocumentContractTests.cs @@ -7,6 +7,18 @@ namespace JobTrackerApi.Tests; public sealed class CoverLetterDocumentContractTests { + [Theory] + [InlineData("en", "Cover letter", "Cover letter text")] + [InlineData("nb-NO", "Søknadsbrev", "Brevtekst")] + public void Empty_optional_details_do_not_create_empty_headings_or_an_unnamed_main_landmark(string language, string title, string bodyLabel) + { + var html = new CoverLetterDocumentRenderer().Render("Reviewed body", new() { Language = language }).Html; + Assert.DoesNotContain("

", html); + Assert.DoesNotContain("

", html); + Assert.Contains($"{System.Net.WebUtility.HtmlEncode(title)}", html); + Assert.Contains($"
", html); + } + [Theory] [InlineData(null, "en")] [InlineData("{}", "en")] diff --git a/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs b/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs index fc5626f..13a2228 100644 --- a/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs +++ b/JobTrackerApi/Services/CoverLetterDocumentRenderer.cs @@ -31,10 +31,15 @@ public sealed class CoverLetterDocumentRenderer } if (list) body.Append(""); var contact = string.Join(" · ", new[] { s.SenderEmail, s.SenderPhone, s.SenderLocation }.Where(v => !string.IsNullOrWhiteSpace(v)).Select(E)); + var letterLabel = s.Language == "nb-NO" ? "Søknadsbrev" : "Cover letter"; + var bodyLabel = s.Language == "nb-NO" ? "Brevtekst" : "Cover letter text"; + var title = string.Join(" — ", new[] { s.Subject, s.SenderName }.Where(value => !string.IsNullOrWhiteSpace(value))); + var senderHeading = string.IsNullOrWhiteSpace(s.SenderName) ? "" : $"

{E(s.SenderName)}

"; + var subjectHeading = string.IsNullOrWhiteSpace(s.Subject) ? "" : $"

{E(s.Subject)}

"; var html = $$""" - {{E(s.Subject)}} — {{E(s.SenderName)}}
-

{{E(s.SenderName)}}

{{E(s.SenderTitle)}}
{{contact}}
-
{{E(s.Date)}}
{{E(s.Recipient)}}

{{E(s.Subject)}}

-
{{body}}
+
+
{{senderHeading}}
{{E(s.SenderTitle)}}
{{contact}}
+
{{E(s.Date)}}
{{E(s.Recipient)}}
{{subjectHeading}} +
{{body}}
"""; return new(html, s.ThemeId, "cover-letter.pdf"); } diff --git a/docs/cv-redesign/VERIFICATION.md b/docs/cv-redesign/VERIFICATION.md index 551e57a..a14a657 100644 --- a/docs/cv-redesign/VERIFICATION.md +++ b/docs/cv-redesign/VERIFICATION.md @@ -164,3 +164,13 @@ Require exactly the four fixture names `light-en-a4`, `dark-nb-a4`, `light-nb-le The historical collector is not parameterized for this feature and hardcodes old totals/roots. A minimal new parent-owned collector must read only this fresh attempt, reconcile current discovery exactly against four ordinary reports, enumerate frontend assertion names and backend/provider TRX test IDs, validate all independent lane reports and process exits, verify no retries/unexpected skips, check CV24 and letter4 oracle membership, bind inputs/runtime/image hashes, and require positive cleanup. Its offline self-check must reject a historical-root report, absent/duplicate cases, bad process exit, source/runtime drift, wrong image and missing cleanup. `--collect-only` must not mean acceptance. Historical Phase 9 evidence stays immutable. Stop/re-freeze on source or runtime drift, stale apphost/static output, migration/schema failure, failed oracle, frozen history loss or unqualified revision behavior. Keep failed attempts separate; never replace a full failed shard with an overlapping focused test total. Report focused/full/backend/frontend/browser/provider/PDF/human outcomes independently. Human screen-reader/touch/device/zoom/print, hosted isolation/MariaDB, provider, deployment and production remain NOT TESTED/BLOCKED as applicable. Production and Phase 10 remain NO-GO. + +## 26 October: blank letter accessibility checkpoint + +Frozen aggregate `final-06` completed its first ordinary shard with 96 passing and four failing cases; source/runtime hashes remained unchanged. Three application-form failures exposed empty optional letter headings and an unnamed document landmark. The fourth fixture incorrectly counted the new read-only preview POST as a durable mutation. This attempt remains failed and retained. + +The shared renderer now omits blank optional headings, supplies an escaped localized fallback document title, and names the document body landmark. Nonempty headings and document layout retain their existing rendering. The mutation fixture excludes only the exact numeric-job preview POST; all save/restore/other commands and durable readbacks remain checked. + +Two new EN/NB regression cases failed before the renderer fix. Fresh full Release backend validation passed 1,229/1,229 with zero skips (`.artifacts/cv-ux-01/letter-empty-backend-successor.trx`); all 11 original application-form browser cases passed (`letter-legacy-forms-01.json`). Guarded actual MariaDB 12.2.2 passed 59/59 with zero skips, matching before/after source hashes, runner exit 0 and positive owned-service cleanup (`provider-final-04`). Independent review found no material issue. These totals overlap earlier focused/full runs and must not be added together. + +The frozen aggregate and current-source Linux image/PDF checks must be rerun after this checkpoint. Earlier aggregate failures and pre-fix images do not establish final acceptance. Full frontend `frontend-final-03` remains current at 981 passing assertions across 107 suites because this checkpoint changes no frontend product or unit-test source. Final acceptance remains pending. diff --git a/job-tracker-ui/e2e/phase7-application-forms.spec.ts b/job-tracker-ui/e2e/phase7-application-forms.spec.ts index aef198f..12519af 100644 --- a/job-tracker-ui/e2e/phase7-application-forms.spec.ts +++ b/job-tracker-ui/e2e/phase7-application-forms.spec.ts @@ -25,7 +25,11 @@ async function json(response: APIResponse | Response, status = 200): Promise< function response(page: Page, method: string, url: string) { return page.waitForResponse(value => value.url() === url && value.request().method() === method); } async function keyboard(page: Page, control: Locator) { await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); } function count(session: Session, method: string, path: string) { return session.requests.filter(value => value.method === method && value.path === path); } -function mutations(session: Session) { return session.requests.filter(value => ['POST', 'PUT', 'PATCH', 'DELETE'].includes(value.method)); } +function mutations(session: Session) { + // The physical preview uses a read-only POST so draft content stays out of URLs. + return session.requests.filter(value => ['POST', 'PUT', 'PATCH', 'DELETE'].includes(value.method) + && !(value.method === 'POST' && /^\/api\/jobapplications\/\d+\/cover-letter\/preview$/.test(value.path))); +} function assetReads(session: Session, id: number) { const start = `/api/jobapplications/${id}/`; return session.requests.filter(value => value.method === 'GET' && value.path.startsWith(start) &&