Merge RB-26 — extract planFileSelection from the upload controller
TE-004: createUploadController performed three inject() calls, an effect()
registration and a window listener before returning, so the real policy
buried inside it — deciding per file whether to reject or start an upload —
was reachable only through a TestBed. planFileSelection in upload.machine.ts
is now that decision as a pure function taking plain {name, type, size}
objects; the controller executes the plan and keeps the one impure step
(crypto.randomUUID()) it can't move. No change to the controller's public
surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
# docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
# libs/shared/docs/behaviour-spec.mdx
This commit is contained in:
@@ -20,7 +20,7 @@ tested where._
|
||||
|
||||
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
||||
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
||||
**is** the suite, reshaped for a business reader. 467 frontend behaviours across
|
||||
**is** the suite, reshaped for a business reader. 474 frontend behaviours across
|
||||
9 contexts; 261 backend behaviours across 42 test
|
||||
classes.
|
||||
|
||||
@@ -798,6 +798,16 @@ classes.
|
||||
- maskTail keeps the requested tail length
|
||||
- masks the whole value when it is not longer than the kept tail
|
||||
|
||||
#### planFileSelection
|
||||
|
||||
- plans nothing for an unknown category
|
||||
- rejects the whole batch with reason "multiple" for a single-file category
|
||||
- does not reject a single file against a single-file category
|
||||
- rejects one file via rejectReason (wrong type)
|
||||
- rejects one file via rejectReason (too large)
|
||||
- plans a FileSelected entry for a file that passes format validation
|
||||
- judges each file independently and preserves order for a mixed multiple-file category
|
||||
|
||||
#### problemDetail
|
||||
|
||||
- extracts the detail from an RFC-7807 ProblemDetails
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
UploadMsg,
|
||||
UploadState,
|
||||
inFlight,
|
||||
rejectReason,
|
||||
planFileSelection,
|
||||
} from '@shared/domain/upload.machine';
|
||||
|
||||
export interface UploadControllerDeps {
|
||||
@@ -75,17 +75,16 @@ export function createUploadController(deps: UploadControllerDeps) {
|
||||
return documentId.startsWith('demo-') ? undefined : uploadContentUrl(documentId);
|
||||
},
|
||||
onFileSelected(categoryId: string, selected: File[]) {
|
||||
const cat = deps.getUpload().categories.find((c) => c.categoryId === categoryId);
|
||||
if (!cat) return;
|
||||
if (!cat.multiple && selected.length > 1) {
|
||||
deps.dispatch({ type: 'FileRejected', categoryId, reason: 'multiple' });
|
||||
return;
|
||||
}
|
||||
for (const file of selected) {
|
||||
const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 });
|
||||
if (reason) deps.dispatch({ type: 'FileRejected', categoryId, reason });
|
||||
else start(categoryId, file);
|
||||
}
|
||||
// The accept/reject decision lives in upload.machine.ts (planFileSelection),
|
||||
// so it's testable without a DOM File. Each plan entry lines up by index with
|
||||
// `selected`: a rejection dispatches as-is; anything else means "start", so the
|
||||
// controller runs the one impure step the plan can't (crypto.randomUUID()).
|
||||
const candidates = selected.map((f) => ({ name: f.name, type: f.type, size: f.size }));
|
||||
const plan = planFileSelection(deps.getUpload(), categoryId, candidates);
|
||||
plan.forEach((msg, i) => {
|
||||
if (msg.type === 'FileRejected') deps.dispatch(msg);
|
||||
else start(categoryId, selected[i]);
|
||||
});
|
||||
},
|
||||
onRemove(localId: string) {
|
||||
shell.cancel([localId]);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
deliveryRefs,
|
||||
inFlight,
|
||||
rejectReason,
|
||||
planFileSelection,
|
||||
} from './upload.machine';
|
||||
|
||||
const cat = (over: Partial<DocumentCategory> = {}): DocumentCategory => ({
|
||||
@@ -309,6 +310,86 @@ describe('rejectReason', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('planFileSelection', () => {
|
||||
const file = (over: Partial<{ name: string; type: string; size: number }> = {}) => ({
|
||||
name: 'diploma.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 1_000_000,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('plans nothing for an unknown category', () => {
|
||||
const s = stateWith([cat({ categoryId: 'diploma' })]);
|
||||
expect(planFileSelection(s, 'unknown', [file()])).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects the whole batch with reason "multiple" for a single-file category', () => {
|
||||
const s = stateWith([cat({ categoryId: 'diploma', multiple: false })]);
|
||||
const plan = planFileSelection(s, 'diploma', [file(), file({ name: 'second.pdf' })]);
|
||||
expect(plan).toEqual([{ type: 'FileRejected', categoryId: 'diploma', reason: 'multiple' }]);
|
||||
});
|
||||
|
||||
it('does not reject a single file against a single-file category', () => {
|
||||
const s = stateWith([cat({ categoryId: 'diploma', multiple: false })]);
|
||||
const plan = planFileSelection(s, 'diploma', [file()]);
|
||||
expect(plan).toHaveLength(1);
|
||||
expect(plan[0].type).toBe('FileSelected');
|
||||
});
|
||||
|
||||
it('rejects one file via rejectReason (wrong type)', () => {
|
||||
const s = stateWith([
|
||||
cat({ categoryId: 'diploma', multiple: true, acceptedTypes: ['application/pdf'] }),
|
||||
]);
|
||||
const plan = planFileSelection(s, 'diploma', [file({ type: 'image/png' })]);
|
||||
expect(plan).toEqual([{ type: 'FileRejected', categoryId: 'diploma', reason: 'type' }]);
|
||||
});
|
||||
|
||||
it('rejects one file via rejectReason (too large)', () => {
|
||||
const s = stateWith([cat({ categoryId: 'diploma', multiple: true, maxSizeMb: 1 })]);
|
||||
const plan = planFileSelection(s, 'diploma', [file({ size: 2_000_000 })]);
|
||||
expect(plan).toEqual([{ type: 'FileRejected', categoryId: 'diploma', reason: 'size' }]);
|
||||
});
|
||||
|
||||
it('plans a FileSelected entry for a file that passes format validation', () => {
|
||||
const s = stateWith([
|
||||
cat({
|
||||
categoryId: 'diploma',
|
||||
multiple: true,
|
||||
acceptedTypes: ['application/pdf'],
|
||||
maxSizeMb: 10,
|
||||
}),
|
||||
]);
|
||||
const plan = planFileSelection(s, 'diploma', [file()]);
|
||||
expect(plan).toEqual([
|
||||
{
|
||||
type: 'FileSelected',
|
||||
categoryId: 'diploma',
|
||||
localId: '',
|
||||
fileName: 'diploma.pdf',
|
||||
fileSizeMb: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('judges each file independently and preserves order for a mixed multiple-file category', () => {
|
||||
const s = stateWith([
|
||||
cat({
|
||||
categoryId: 'diploma',
|
||||
multiple: true,
|
||||
acceptedTypes: ['application/pdf'],
|
||||
maxSizeMb: 10,
|
||||
}),
|
||||
]);
|
||||
const plan = planFileSelection(s, 'diploma', [
|
||||
file({ name: 'a.pdf' }),
|
||||
file({ name: 'b.png', type: 'image/png' }),
|
||||
file({ name: 'c.pdf' }),
|
||||
]);
|
||||
expect(plan.map((m) => m.type)).toEqual(['FileSelected', 'FileRejected', 'FileSelected']);
|
||||
expect((plan[1] as { reason: string }).reason).toBe('type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inFlight', () => {
|
||||
it('returns only queued/uploading uploads', () => {
|
||||
let s = select(stateWith([cat({ multiple: true })]), 'diploma', 'u1');
|
||||
|
||||
@@ -119,6 +119,43 @@ export function rejectReason(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The file-selection policy behind `<app-document-upload>`: for each candidate file,
|
||||
* decide whether to reject it or accept it, with no I/O. An unknown category plans
|
||||
* nothing (mirrors the controller's own "unknown category" guard). Choosing more than
|
||||
* one file for a single-file category rejects the whole batch with reason `'multiple'`
|
||||
* and skips the per-file checks; otherwise each file is judged by `rejectReason`.
|
||||
*
|
||||
* An accepted file plans a `'FileSelected'` entry with a placeholder `localId: ''` —
|
||||
* a real id needs `crypto.randomUUID()`, an impure call that stays in
|
||||
* `createUploadController`. The controller reads only each entry's `type` (never its
|
||||
* fields) to decide, at that same array index, whether to dispatch the rejection as-is
|
||||
* or start the upload for the original file — so the placeholder is never dispatched.
|
||||
*/
|
||||
export function planFileSelection(
|
||||
state: UploadState,
|
||||
categoryId: string,
|
||||
files: { name: string; type: string; size: number }[],
|
||||
): UploadMsg[] {
|
||||
const cat = state.categories.find((c) => c.categoryId === categoryId);
|
||||
if (!cat) return [];
|
||||
if (!cat.multiple && files.length > 1) {
|
||||
return [{ type: 'FileRejected', categoryId, reason: 'multiple' }];
|
||||
}
|
||||
return files.map((file): UploadMsg => {
|
||||
const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 });
|
||||
return reason
|
||||
? { type: 'FileRejected', categoryId, reason }
|
||||
: {
|
||||
type: 'FileSelected',
|
||||
categoryId,
|
||||
localId: '',
|
||||
fileName: file.name,
|
||||
fileSizeMb: file.size / 1e6,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Map one upload's status, leaving the rest of the list untouched. */
|
||||
function mapUpload(s: UploadState, localId: string, f: (u: Upload) => Upload): UploadState {
|
||||
return { ...s, uploads: s.uploads.map((u) => (u.localId === localId ? f(u) : u)) };
|
||||
|
||||
Reference in New Issue
Block a user