# QA: Media page, part 1 (browse and manage) ## Scope note (informational) SPA only. The media API already exists (slice A), so there is no HTTP-level Go test in this slice and no `test/` file. The Go suite is run only to confirm nothing regressed (`task test`). Slice B2 (upload, multi-select, bulk bar, Duplicates content, merge, drag and drop, rate limit) is specified in features/media_upload_selection_duplicates.feature and its .qa.md. Toast ids: "media-url-copied-{id}", "media-renamed-{id}", "media-deleted-{id}", "media-not-found-{id}". ## Automated (Vitest + Vue Test Utils, `task test:web`) Mock `useApi`'s `request` (or the global fetch it wraps) per case for `GET /v1/media/`, `GET /v1/media/{id}/usage`, `GET /v1/media/?hash=`, `PATCH /v1/media/{id}` and `DELETE /v1/media/{id}`, plus `navigator.clipboard.writeText` and `navigator.onLine`. No real backend. Mount MediaPage inside DashboardShell with a verified session and a test router at /media. Sample data is the 12-image fixture in the .feature Background (same ids, filenames, sizes, dimensions, used_in, uploaders and dates as the board's script), with counts { all: 12, unused: 2, duplicates: 2 }. Use fake timers for the 150 ms skeleton delay and the 120 ms search debounce. Logic and copy: 1. **Formatting** (pure functions) - size: 8192 bytes to "8 KB", 880640 to "860 KB", 4089446 to "3.9 MB"; dimensions "4032 × 3024"; null width and height to "Vector"; date "12 Sep 2026" from `created_at`; type from content_type (image/jpeg "JPEG", image/png "PNG", image/webp "WEBP", image/svg+xml "SVG"); accessible name for used_in 2, 1 and 0 ("used in 2 articles", "used in 1 article", "unused"). 2. **Query string mapping** - Examples-driven from the .feature: each URL produces the exact request query and control state; unknown values fall back to defaults; changing a control rewrites the URL and drops default values. 3. **Default** - 12 cards in fixture order, tabs "All 12" "Unused 2" "Duplicates 2", the Duplicates badge only when duplicates > 0, "Select all" and "12 images" in the Select all row, breadcrumb "Media". 4. **Badges and accessible names** - the 5 Examples rows (badges and aria-label / accessible name). 5. **Grid, few images** - 3 items; tabs "All 3" "Unused 1" "Duplicates" without a badge. 6. **List view** - desktop: table with the 7 column headers and 12 rows, the Used in cell underlined count or Unused badge; mobile: card list with "3.9 MB · 4032 × 3024 · JPEG" and "Marta Alves · 12 Sep 2026"; clicking the Used in count opens the panel. 7. **Duplicates tab** - covered by the B2 spec. 8. **Loading** - unresolved GET: nothing before 150 ms; after 150 ms the skeleton (10 cards, 6 below md) and aria-busy; resolve; cards replace it; same on retry and on search, tab and sort change, in list view too. 9. **Empty library** - zero items and counts 0 with no q: toolbar and tabs absent, copy exact, "Upload an image" enabled, the footnote "PNG, JPEG, WEBP, SVG · max 10 MB each". 10. **No results** - unused + "wall": title `No images match "wall"`, text "Nothing matches in Unused. Check the spelling, or clear the filters."; "Clear filters" resets q and filter and restores the cards. 11. **Keyboard** - each card is a button; Enter opens the panel; Space toggles selection and opens nothing; arrow keys move focus (left, right, up, down by one row, stopping at the edges); Escape closes the panel and focus returns to the card. 12. **Detail panel, in use** - usage called once for id 1, title, actions, metadata rows, article rows with links to /article/{id}/edit and Online / Draft badges, footnote text. 13. **Detail panel, unused** - no usage request; "Not used in any article" and the safe-to-delete text. 14. **Detail panel, duplicate** - hash query used; alert title "Identical to 2 other images" and "floorboards-copy.jpg and IMG_2208.jpg have the same content." (the image itself is excluded); the "Review duplicates" action is enabled and selects the Duplicates tab. 15. **Copy URL** - clipboard called with `{origin}{url}`; toast id "media-url-copied-1", icon circle-check, title "URL copied", text "Paste it wherever you need the image address.". 16. **Rename dialog** - title, description, field "north-wall" with ".jpg" suffix, help text, Cancel then Rename. 17. **Rename validation** - "", "---", "!!!": error "Enter a filename." on submit and on blur, no PATCH. 18. **Rename saving** - PATCH /v1/media/1 body { filename: "north-wall-01" } (no extension); the field and buttons disabled, "Renaming…", dialog not dismissible by Escape or backdrop. 19. **Rename failed** - 500 and network rejection: alert copy exact, text kept, buttons enabled. 20. **Rename done** - 200: panel title, card and row updated in place, order unchanged; toast id "media-renamed-1", title "Image renamed", text `It's now "north-wall-01.jpg". The URL is the same.` 21. **Delete, unused** and **Delete, in use** - dialog titles, descriptions with the exact quotes and counts, the article list with badges only in the in-use variant, "Delete image" danger. 22. **Delete in progress** - DELETE /v1/media/8; buttons disabled; "Deleting…"; not dismissible. 23. **Delete failed** - 500 and network rejection: alert copy exact, item untouched. 24. **Delete done** - 204: dialog and panel closed, card removed, focus on the next card (heading when last), toast id "media-deleted-8", title "Image deleted", text `"window-frame.jpg" is gone from your library.`; a background list refresh without skeleton follows and updates the counts. 25. **404 Examples-driven** - DELETE, PATCH, usage: everything closes, the item is removed, toast id "media-not-found-8", icon info, title "That image no longer exists", text "It was deleted somewhere else, so we removed it from the library.". 26. **List failed** - 500 and network rejection: alert copy exact, toolbar visible; "Try again" re-requests, shows skeleton, then the list. 27. **Offline** - onLine false: banner copy exact, Upload, Rename and Delete disabled, Copy URL and panel still work; back online restores them. 28. **401 / 403** - handled by useApi (redirect target strings asserted); no local media copy. 29. **Sidebar and drawer** - Media between Articles and Users with the images icon, active on /media, in the desktop sidebar and the mobile drawer; the route has meta.auth "verified" and meta.title "Media" (routerGuard spec extended). 30. **Header** - title, subtitle, and the Upload button (also the mobile pinned 48 px variant). ## Visual (`task visual:check -- media`, desktop and mobile, light and dark) Reference: docs/design/screens/media.md and `source/MediaFrame.dc.html` (group "Media" in the back-office board). Add each setup to `web/visual/states.ts` first, mocking `/v1/media/`, `/v1/media/{id}/usage` and `/v1/media/?hash=` from the board's own script: the 12 images (ids, filenames, sizes, dimensions, used in, uploaders, absolute dates), and the article rows for the panels ("How we price a bathroom renovation" Online, "Winter checklist for timber windows" Draft for north-wall.jpg). None of the B1 states depends on the current date (all dates are absolute), so none is `skip: 'clock-sensitive'`. The board draws its thumbnails as placeholder tiles (a striped tone fill with an image icon, the RenewCMS mark on the SVG, a checkerboard behind PNG and SVG), not photographs. The mocked image URLs must render the same way, or the 10 % gate fails for a reason unrelated to the code. Harness note: the B2 states now have setups and no longer carry `skip: 'pending'`. Setups for this slice (name in screens/media.md, then what must be drawn): - [ ] Grid, many images: 12 cards, tabs 12 / 2 / 2, Select all row showing "12 images" only. - [ ] Grid, few images: ids 1, 4, 8; checkerboard behind the SVG. - [ ] List view: table at desktop, card list at mobile. - [ ] Loading: skeleton cards (10 desktop, 6 mobile), toolbar visible. - [ ] Empty library, first run: no toolbar, "No images yet" panel and the footnote. - [ ] No results: unused + "wall". - [ ] Hover, focus and keyboard: hover on id 2, focus ring on id 3 (the checkbox reveal is covered by B2). - [ ] Detail panel: in use (id 1), unused (id 8), duplicate (id 12). - [ ] Copy URL: id 1 panel plus the toast. - [ ] Rename: dialog, saving, validation, failed, done (id 1, "north-wall-01"). - [ ] Delete: unused (id 8), in use (id 1), in progress (id 1), failed (id 8), done (grid without id 8 plus toast). - [ ] Image already gone (404): grid without id 8 plus the info toast. - [ ] List failed to load: alert with "Try again". - [ ] Offline: banner and disabled Upload. The B2 states are listed in features/media_upload_selection_duplicates.qa.md. Manual checks screenshots cannot cover: - [ ] Keyboard and focus order: Tab visits the search, tabs, sort, toggle, then one stop per card; arrow keys move across the grid; Enter opens the panel; focus is trapped in the panel and in dialogs, starts on Cancel in dialogs, and returns to the card on close; after a delete focus lands on the next card. - [ ] Hover, motion and reduced motion: card hover shadow, skeleton pulse, panel and dialog transitions stop under reduced motion. - [ ] Screen reader: the card's accessible name reads correctly. ## Manual verification - **Real round trips against a running server** (`task dev`): list with real counts, sorting, search, usage, rename (extension kept, URL unchanged), delete, a concurrent 404. Vitest only mocks `useApi`. - **Real image rendering**: the thumbnails and previews load from the real `url` and the checkerboard shows behind real transparent PNG and SVG files. - **Real clipboard write** in a real browser. - **Toast placement and stacking** on a live app. - **Counts after a delete** against a live server: the background refresh gives the true Unused and Duplicates counts. - **A library above 25 images**: amended by `features/media_pagination_screen.feature`. B1 used to request `limit=100` and ignore `next_cursor`; the page now requests `page=1&size=25` (or the stored size) and shows a paginator, so every image is reachable.