8.2 KiB
QA: Media page, part 2 (upload, selection, duplicates)
Scope note
SPA only. No Go test and no test/ file; run the Go suite once (task test) to confirm nothing regressed. The server never returns 429 for POST /v1/media/ today (no rate limiter is mounted on the media router), so every 429 case is proven with a mock only.
Automated (Vitest + Vue Test Utils, task test:web)
Mount MediaPage in DashboardShell with a verified session and a test router at /media, as in the B1 spec. Mock useApi's request for GET/DELETE/merge and the list; replace XMLHttpRequest with a controllable stub (per instance: open, send, abort, upload.onprogress with loaded/total, status, responseText, getResponseHeader('Retry-After'), load, error, abort events). Stub crypto.subtle.digest to a known hex per file with vi.stubGlobal, and to undefined for the unavailable case. Use fake timers for the 150 ms skeleton, the 120 ms search debounce and the 1 s Retry-After tick. Sample data is the 12-image fixture of media_browse.feature. Files are built with new File([...], name, { type }) and a size override for over-limit sizes.
- Hashing lib (pure): digest of a known byte array gives the expected lowercase 64-char hex;
crypto.subtleundefined gives null; nothing in the lib imports from the Media page. - Browser checks: the six rows of the .feature, plus 10485760 bytes accepted and 10485761 rejected; size beats type; a rejected file triggers no hash call, no GET and no XHR; titles and text exact.
- Entry points: Upload (desktop header), the mobile pinned button and "Upload an image" each click the hidden
input[type=file](multiple, exact accept list). - Drop overlay: dragenter with Files shows the copy exactly; dragleave hides; drop starts uploads; a drag without Files shows nothing; mobile pinned button hidden while shown.
- Queue: 6 valid files give at most 2 XHRs open at once; order kept; header "Uploading 6 images" and "N of 6 done" counts settled rows; "Uploading 1 image" singular; progress 1.1 of 2.6 MB reads "Uploading 1.1 of 2.6 MB" with a 42 % bar; 201 gives "Added to your library"; a silent list reload follows; files added while open are appended.
- Duplicate check: GET
?hash=...&page=1&size=100precedes POST; a match gives the Duplicate row with exact text (oldest match named); slot released; "Use existing" drops the row and selects the id (bulk bar "1 selected", live region text), including the reset-to-All path; "Upload anyway" sends POST; crypto undefined and lookup failure skip straight to POST. - Server statuses: 201, 413, 415, 400, 500, network error: row copy and icon exact; "Try again" re-sends only that file with no new GET
?hash=. - Cancel: row x aborts that XHR and removes the row, the next file starts; "Cancel all" aborts all and removes waiting rows; panel closes when empty.
- Summary: Examples-driven summary lines; "Upload finished" / "{n} images" and "Dismiss"; "Nothing was uploaded" with "3 files were rejected" and with "2 files need attention", no summary; live-region text; Dismiss removes every row.
- 429:
Retry-After: 42gives the alert copy and "Try again in 0:42" (disabled, timer icon; mobile pinned button too); the 429'd file returns to Waiting; advancing 42 s re-enables, removes the alert and resumes the queue; Added rows kept; missing header gives 1:00; drops ignored while counting. - Offline: Upload, "Upload an image", bulk "Delete" and "Merge into this one" disabled; drops ignored; Clear and selection still work; back online re-enables.
- Selection: checkbox visibility (hover, focus, any selection, always on mobile); tick and Space toggle; Enter opens the panel and ticking does not; live-region texts for select, unselect and "Selection cleared"; the checkbox is not a tab stop; select-all states (unchecked, partial, checked) in the grid row and the table header (no row in the mobile card list); Escape closes the panel first and clears the selection next; Clear; selection cleared on q/tab/sort change, kept on view change.
- Bulk bar: copy, buttons, desktop and mobile placement classes, replaces the mobile pinned button.
- Bulk delete: dialog copy exact (title, description with the summed count, rows "Used in 2", "Used in 3", "Unused"), singular form; no usage request; in progress (one DELETE per id, buttons disabled, not dismissible); done (cards leave, selection empty, focus target, toast id and text, silent reload); partly failed (two removed, failed one stays selected, bar "1 selected", toast with Retry and no auto-dismiss, Retry re-sends only the failed DELETE); several failures title; 404 counted as deleted; all failed gives the inline alert.
- Duplicates tab: enabled, URL and request (
filter=duplicates, size=100, page 1, 2, ... until total is reached; amended byfeatures/media_pagination_screen.feature), deep link, "Review duplicates" enabled and switching tab; sort and view toggle shown (sort sent with the request, toggle keeps the preference in the URL); grouping by sha256, group order, headers (including the SVG/unknown uploader case), column order, "Suggested" tag and footer, "Uploaded 14 Jun 2026 by Inès Duarte", "Used in 4 articles" opens the panel; suggested-copy Examples; empty state and "Back to all images"; badge absent at 0. - Merge: dialog copy exact for floorboards.jpg and another kept copy, singular form; POST body
{ keep_id: 12, merge_ids: [5, 6] }; in progress not dismissible; 200 gives group removed, toast id "media-merged-12" and text, live-region text, silent reload, "No duplicates" when last; 500 and network give the alert; 409 gives the alert and a reload; 404 gives the info toast and closes. - Global errors: 401 and 403 from XHR upload, merge, delete and hash lookup call the same redirect handler as useApi; no local copy.
- B1 regression: media_browse specs updated for the superseded statements and the rest still passes.
Visual (task visual:check -- media, desktop and mobile, light and dark)
Reference: docs/design/screens/media.md and source/MediaFrame.dc.html. The 20 B2 states lose skip: 'pending' and get a setup in web/visual/states.ts. None depends on the current date (dates are absolute), so none is skip: 'clock-sensitive'. Where a board frame contradicts the prose or fixture (bulk delete done / partly failed, "4 articles", tab badge at zero, "2 of 6 done", merge done with one group left), the user corrects the board; no exception is added.
Harness requirements:
- Upload states use an init-script XHR stub that holds a request at 1.1 of 2.6 MB (42 %); files come from
setInputFiles; the duplicate state mocksGET /v1/media/?hash=by the hash of the file's bytes. - The drag state dispatches a synthetic
dragenterwithdataTransfer.types=['Files']. - The 429 state freezes timers with Playwright
page.clockso the button reads "0:42".
Setups:
- Selection and bulk bar (ids 1, 2, 8; partial select-all)
- Bulk delete: confirm, in progress, partly failed, done
- Upload: drag over the page, several files mixed, summary, rejected files (413, 400), too many requests (429)
- Duplicates: groups, empty
- Merge: confirm, in progress, failed, conflict (409), done
Manual checks screenshots cannot cover:
- Keyboard and focus order: Space toggles selection, Enter opens the panel, Escape order (panel first, then selection); focus after a bulk delete; dialog focus starts on Cancel.
- Drag and drop with real files from the OS, including a folder and a non-image drop.
- Progress bar smoothness, hover and focus rings, reduced motion.
- Screen reader announcements for selection, upload completion and merge.
Manual verification
- Real upload round trip against a running server (
task dev): progress on a large file, 413 at 10 MB + 1 byte, 415, 400 for a corrupt PNG, uploading the same bytes twice (duplicate row, "Upload anyway", Duplicates tab). - Real SHA-256 over a 10 MB file, and the skip path on an insecure origin (plain http on a LAN address).
- Real merge: article bodies rewritten, counts after the refresh.
- Real 429 cannot be checked: the media router has no limiter.
- Real bulk delete of 20+ images with one deleted from another tab first (404 handling).