docs: archive the finished backlogs (RD-30)
Two backlog trees are complete: `docs/project/backlog/` (75 files, every WP done) and `docs/project/refactor-backlog-setup/` (the arc before it). Move both under `docs/project/archive/` with `git mv`, so history stays intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them, because it points at the now-archived backlog README. Add `docs/project/archive/README.md`. It states that these trees are historical and names the two directories that are still live. Repoint every inbound reference named in RD-30's Files table: CLAUDE.md, the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the `document-feature` and `new-ssp` skills, and the readable-codebase PLAN, README, and RD-19 ticket. Fix two upward-relative links inside the moved WP files (WP-68, WP-69) that gained a directory level and would otherwise break. Repoint `.prettierignore`'s two agent-prompt exclusions to their new path, so prettier keeps leaving those files' exact wording alone. Mark RD-30 done and check off its acceptance criteria; flip its README row to done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
# RB-28 — `BLOB_PRESENTER` token unlocks the three blob-to-browser success paths
|
||||
|
||||
Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-006 ·
|
||||
`99-backlog.md` RB-28
|
||||
|
||||
## What was wrong
|
||||
|
||||
Three application-layer commands each ended in raw DOM/browser calls that jsdom cannot
|
||||
meaningfully execute: `StamdataStore.download()`
|
||||
(`libs/beheer/src/application/stamdata.store.ts`) did `URL.createObjectURL` →
|
||||
`document.createElement('a')` → `a.click()` → `URL.revokeObjectURL`;
|
||||
`BriefStore.previewLetter()` (`apps/ssp/src/app/brief/application/brief.store.ts`) and
|
||||
`OrgTemplateStore.proefbrief()` (`apps/ssp/src/app/brief/application/org-template.store.ts`)
|
||||
both did `window.open(URL.createObjectURL(blob), '_blank')`. Because the call was the
|
||||
last statement of each command, TE-006 recorded the whole success path as effectively
|
||||
unassertable, and `download()`'s two-clause guard (`if (!s || !this.canDownload())
|
||||
return;`) as permanently dark on its true branch.
|
||||
|
||||
## What changed
|
||||
|
||||
One new file, `libs/shared/src/application/blob-presenter.ts`, mirroring the
|
||||
`SESSION_PORT` token already in that folder — an interface, a production
|
||||
implementation, and an `InjectionToken`:
|
||||
|
||||
```ts
|
||||
export interface BlobPresenter {
|
||||
open(blob: Blob): void;
|
||||
download(blob: Blob, filename: string): void;
|
||||
}
|
||||
|
||||
const realBlobPresenter: BlobPresenter = {
|
||||
open(blob) {
|
||||
window.open(URL.createObjectURL(blob), '_blank');
|
||||
},
|
||||
download(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
|
||||
export const BLOB_PRESENTER = new InjectionToken<BlobPresenter>('BLOB_PRESENTER', {
|
||||
providedIn: 'root',
|
||||
factory: () => realBlobPresenter,
|
||||
});
|
||||
```
|
||||
|
||||
`open()` never revokes the object URL (the tab it opens outlives the call —
|
||||
`BriefStore.previewLetter`'s original comment already said so and is preserved,
|
||||
moved onto the token's own doc comment); `download()` does revoke, once the click has
|
||||
fired. This asymmetry is preserved deliberately, not unified — the two call sites
|
||||
behaved differently before this ticket and still do.
|
||||
|
||||
Each of the three commands now injects `BLOB_PRESENTER` and calls it instead of the DOM
|
||||
directly:
|
||||
|
||||
| File | Before (last statement) | After |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| `stamdata.store.ts` | `createObjectURL` → `createElement('a')` → `click()` → `revokeObjectURL` (6 lines) | `this.blobPresenter.download(blob, \`${s.table.id}.json\`);` |
|
||||
| `brief.store.ts` | `window.open(URL.createObjectURL(r.value), '_blank')` | `this.blobPresenter.open(r.value);` |
|
||||
| `org-template.store.ts` | `window.open(URL.createObjectURL(r.value), '_blank')` | `this.blobPresenter.open(r.value);` |
|
||||
|
||||
`OrgTemplateStore.previewUrlFor` (added by RB-24, a different seam — a document
|
||||
content URL for an `<a href>`, not a blob handoff) is untouched.
|
||||
|
||||
## Tests added
|
||||
|
||||
**`libs/beheer/src/application/stamdata.store.spec.ts`** — a new
|
||||
`StamdataStore.download (RB-28)` describe block with a recording fake `BlobPresenter`:
|
||||
|
||||
1. Does not call the presenter while `canDownload()` is false because nothing is dirty
|
||||
yet — the guard's previously-dark true branch, first clause.
|
||||
2. Does not call the presenter while previewing a date, even with a real edit present —
|
||||
the guard's true branch, second clause.
|
||||
3. **The success path**, asserting `toJson(...)`'s exact output reaches the file: reads
|
||||
the recorded blob's text and compares it byte-for-byte against a direct call to
|
||||
`toJson(store.table()!, store.rows())`, and asserts the filename is
|
||||
`professions.json`.
|
||||
|
||||
**`apps/ssp/src/app/brief/application/brief.store.spec.ts`** — the existing
|
||||
`BriefStore.previewLetter` describe block's success test previously spied directly on
|
||||
`window.open`/`URL.createObjectURL` (both already jsdom-spyable, since the properties
|
||||
exist even though calling them for real throws "not implemented"). It now provides the
|
||||
recording fake via `BLOB_PRESENTER` and asserts `opened` holds exactly the resolved
|
||||
blob — the same outcome, reached through the new seam instead of monkey-patching two
|
||||
global browser objects.
|
||||
|
||||
**`apps/ssp/src/app/brief/application/org-template.store.spec.ts`** (new file —
|
||||
`OrgTemplateStore` had no spec at all before this ticket) — a
|
||||
`OrgTemplateStore.proefbrief (RB-28)` describe block: the success path (presenter
|
||||
receives the resolved blob, no error) and the failure path (presenter never reached,
|
||||
error surfaced). A `Partial<UploadAdapter>` stub with a no-op `categoriesResource`
|
||||
(status `'idle'`) satisfies the store's constructor effect without touching the
|
||||
logo-upload sub-state, which these tests do not exercise.
|
||||
|
||||
## Verified red without the fix
|
||||
|
||||
Broke `StamdataStore.download()` with an `Edit` (not `git checkout`): changed the
|
||||
filename from `` `${s.table.id}.json` `` to `` `${s.table.id}.csv` ``. Ran the new
|
||||
success-path spec:
|
||||
|
||||
```
|
||||
AssertionError: expected 'professions.csv' to be 'professions.json' // Object.is equality
|
||||
|
||||
Expected: "professions.json"
|
||||
Received: "professions.csv"
|
||||
❯ libs/beheer/src/application/stamdata.store.spec.ts:134:36
|
||||
```
|
||||
|
||||
Re-applied the correct filename with a second `Edit`; the full `stamdata.store.spec.ts`
|
||||
file (6 tests) went green again.
|
||||
|
||||
## Verification
|
||||
|
||||
- **`grep` for remaining DOM blob calls** in all three stores —
|
||||
`grep -nE "window\.open|createObjectURL|revokeObjectURL|createElement\('a'\)|\.click\(\)"` —
|
||||
zero matches. The only occurrences of those calls anywhere in `apps`/`libs` are inside
|
||||
`blob-presenter.ts` itself (checked with a second, unscoped grep — no fourth inlined
|
||||
handoff exists).
|
||||
- `npm run lint`: clean.
|
||||
- `npm run dep:check`: unaffected (no new import direction — `libs/shared` still does not
|
||||
depend on `libs/beheer`; both `libs/beheer` and `apps/ssp/brief` import the new token
|
||||
from `libs/shared`, never the reverse).
|
||||
- `npm test` (all four projects): all pass — ssp 276, behandelportal 37, shared 138,
|
||||
beheer 26 (up from 23; +3 for the new `download()` describe block).
|
||||
- Coverage, `npm run test:coverage` narrowed per project:
|
||||
- `libs/beheer/src/application/stamdata.store.ts` — **before** BRH 15 / BRF 37
|
||||
(40.5% branch, confirmed against the current tree, matching TE-006's citation
|
||||
exactly); **after** BRH 25 / BRF 37 (**67.6% branch**). `libs/beheer/src/application`
|
||||
has exactly this one file, so the module figure moves the same way.
|
||||
- `apps/ssp/src/app/brief/application/brief.store.ts` — **before** BRH 39 / BRF 72
|
||||
(54.2% branch). This is higher than TE-006's cited 32/64 (50%) because RB-22/RB-23
|
||||
already added branches (the 404-tolerance path) since the finding was written — see
|
||||
"What TE-006 got wrong" below. **After**: BRH 39 / BRF 72, unchanged — swapping the
|
||||
global-spy assertions for the injected fake changes how the success branch is
|
||||
reached in the spec, not whether it is reached; it was already covered before this
|
||||
ticket (see below).
|
||||
- `apps/ssp/src/app/brief/application/org-template.store.ts` — no spec existed before
|
||||
this ticket, so there is no meaningful "before" branch figure for it specifically.
|
||||
**After**: BRH 17 / BRF 77, including both `proefbrief()` branches newly covered.
|
||||
- `npm run ci` (foreground, `timeout: 600000`, no background/Monitor): result reported
|
||||
in the implementing agent's final answer.
|
||||
|
||||
## What TE-006 got wrong
|
||||
|
||||
TE-006 states: "`brief.store.spec.ts` demonstrates this exactly: it tests
|
||||
`previewLetter`'s failure case ... and cannot test the success case." This is not
|
||||
accurate for the code as it stood at the start of this ticket. The spec already had an
|
||||
`'opens the composed letter in a new tab on success'` test that used
|
||||
`vi.spyOn(URL, 'createObjectURL')` and `vi.spyOn(window, 'open')` to assert the success
|
||||
path — jsdom defines both properties (as functions that throw "not implemented" if
|
||||
actually invoked), so `vi.spyOn` can already replace them, and the pre-existing test
|
||||
did. That test passed both before and after this ticket's change; this ticket did not
|
||||
newly unlock `previewLetter`'s success path, it moved an already-passing assertion off
|
||||
two hand-spied global browser objects and onto the new injectable seam. `git log
|
||||
--follow -p` on the spec file shows this test dates to the WP-67 monorepo merge, not to
|
||||
any of RB-22/23/24.
|
||||
|
||||
The seam is still worth having: `StamdataStore.download()`'s success path (five
|
||||
DOM/API calls in a row: `createObjectURL`, `createElement`, `.href`, `.download`,
|
||||
`.click()`, `revokeObjectURL`) is a materially harder thing to spy on faithfully than a
|
||||
single `window.open` call, and was in fact still dark before this ticket (no
|
||||
`download()` test of any kind existed). `OrgTemplateStore.proefbrief()` also had no
|
||||
spec at all. TE-006's diagnosis (three commands share the same class of problem, one
|
||||
token fixes all three) is sound; only the specific "cannot test" claim about
|
||||
`previewLetter` overstates what was true for that one call site. Scope was not reduced
|
||||
because of this — all three call sites are migrated per the ticket's own instruction to
|
||||
ship them together rather than half-adopt the seam.
|
||||
|
||||
## What this ticket did not touch
|
||||
|
||||
`OrgTemplateStore.previewUrlFor` (RB-24) — confirmed present and unchanged at
|
||||
`org-template.store.ts:78`. `libs/shared/src/application/upload-shell.service.ts`
|
||||
(RB-25) and `upload-controller.ts` (RB-26) — not read beyond what RB-24's own note
|
||||
already described, not edited. `libs/shared/docs/behaviour-spec.mdx` — regenerated by
|
||||
`npm run gen:behaviour-spec` (part of `npm run ci`) to reflect the new/renamed test
|
||||
names; never hand-edited.
|
||||
Reference in New Issue
Block a user