refactor(shared): move the accept/reject decision into planFileSelection (RB-26)

createUploadController required inject(), an effect(), and a window listener
before a test could reach it. The file-selection policy trapped behind that
cost now lives in a pure function, planFileSelection, in upload.machine.ts.

planFileSelection takes plain { name, type, size } objects, not File, and
decides per file whether to reject it or accept it, with no I/O. The
controller executes the plan: it dispatches a rejection as-is, and starts the
upload for an accepted file (the one step that needs crypto.randomUUID()).

A new spec covers the three outcomes: the 'multiple' batch rejection, a
rejectReason-based rejection, and the accept case, plus order in a mixed
batch. Verified red-then-green with a temporary stub, undone by a second edit.

No change to the controller's public surface or to the calling organism.
previewUrlFor (added by RB-24) is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-28 08:28:30 +02:00
co-authored by Claude Opus 5
parent 693016445b
commit 95bb77395e
6 changed files with 284 additions and 48 deletions
@@ -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]);