feat: media page T1
This commit is contained in:
1 parent
67d456baf6
commit
008b0dfbb9
46 files changed
+5742
-204
No files matched your search
@@ -14,11 +14,9 @@ Feature: Media page, part 1: browse and manage
|
||||
breadcrumb reads "Media" (mobile title "Media")
|
||||
And this slice is SPA only: the API from slice A already exists and is used unchanged, and
|
||||
no Go code, use case or repository changes
|
||||
And this slice covers browse and manage only. Upload, multi-select, the bulk bar, the
|
||||
Duplicates tab content, duplicate merge, drag-and-drop and the rate-limited state are slice
|
||||
B2, and this slice renders none of their UI
|
||||
And the visual states of slice B2 carry skip 'pending' in web/visual/states.ts, print PENDING
|
||||
and are never counted as passing
|
||||
And upload, multi-select, the bulk bar, the Duplicates tab content, duplicate merge,
|
||||
drag-and-drop and the rate-limited state are specified in
|
||||
features/media_upload_selection_duplicates.feature
|
||||
And useMedia is a composable over useApi. It exposes the list state (items, counts, loading,
|
||||
error), the request functions for list, usage, rename and delete, and the copy helper, and
|
||||
it maps ApiError and NetworkError to the states below
|
||||
@@ -30,9 +28,8 @@ Feature: Media page, part 1: browse and manage
|
||||
means grid); a value outside these sets is dropped and the default applies; the names
|
||||
mirror the API's own q, filter and sort parameters
|
||||
And the header shows the title "Media", the subtitle "Every image you've uploaded, ready to
|
||||
use in any article." and a primary "Upload" button (icon upload) on the right; in this slice
|
||||
that button is rendered disabled and does nothing, and on mobile it is the full-width 48 px
|
||||
button pinned to the bottom, disabled the same way
|
||||
use in any article." and a primary "Upload" button (icon upload) on the right; on mobile it is
|
||||
the full-width 48 px button pinned to the bottom
|
||||
And the toolbar holds the tabs "All", "Unused" and "Duplicates" with a count each, then the
|
||||
search field (placeholder "Search by filename"), the sort menu (options "Newest", "Oldest",
|
||||
"Name", "Largest", sent as sort=newest, oldest, name, largest) and the grid/list toggle
|
||||
@@ -40,10 +37,7 @@ Feature: Media page, part 1: browse and manage
|
||||
And the tab counts come from the response's counts object (all, unused, duplicates). The
|
||||
Duplicates count is a number of groups, drawn as an info-tinted badge only while it is
|
||||
above 0. The All and Unused counts are drawn as plain muted mono numbers, as in the board
|
||||
And the Duplicates tab is rendered and disabled in this slice (aria-disabled, not
|
||||
focusable, no navigation), so it keeps its place and its count badge but leads nowhere;
|
||||
slice B2 enables it and replaces the content area with the groups view. A ?filter=duplicates
|
||||
in the URL is treated as an unknown value and falls back to All
|
||||
And the Duplicates tab selects the groups view and ?filter=duplicates is a valid value
|
||||
And the grid is 5 columns on desktop and 2 on mobile, with a 16 px gap (10 px on mobile),
|
||||
and each card is one button holding MediaThumb (4:3, a checkerboard behind PNG and SVG),
|
||||
the filename (medium weight, ellipsis), "size · dimensions" and the MediaBadges; an SVG's
|
||||
@@ -65,9 +59,8 @@ Feature: Media page, part 1: browse and manage
|
||||
trailing chevron-right); in the table the "Used in" cell is an underlined count that
|
||||
opens the detail panel, and reads the Unused badge when used_in is 0
|
||||
And the checkbox column of the table (36 px), the 44 px checkbox column of the card list and
|
||||
the "Select all" row above the grid keep their space in the layout but show no checkbox
|
||||
and no "Select all" text in this slice (the row shows only "12 images"); slice B2 fills
|
||||
them
|
||||
the "Select all" row above the grid are specified in
|
||||
features/media_upload_selection_duplicates.feature
|
||||
And the detail panel is a USlideover: 460 px on the right on desktop (a close button x),
|
||||
full screen on mobile with an arrow-left back button; its title is the filename; the body
|
||||
holds the 16:10 preview (4:3 on mobile), the actions "Copy URL" (icon link), "Rename"
|
||||
@@ -107,7 +100,7 @@ Feature: Media page, part 1: browse and manage
|
||||
Duplicates count drawn as an info-tinted badge
|
||||
And the grid shows the 12 images in exactly the fixture's order, 5 per row on desktop and 2
|
||||
on mobile, each card with its thumbnail, filename, "size · dimensions" and badges
|
||||
And the "Select all" row above the grid shows only "12 images"
|
||||
And the "Select all" row above the grid reads "Select all" and "12 images"
|
||||
And the toolbar shows "Search by filename", the sort "Newest" and the grid toggle selected
|
||||
|
||||
Scenario Outline: Each card's badges and accessible name follow the image
|
||||
@@ -175,11 +168,6 @@ Feature: Media page, part 1: browse and manage
|
||||
Then the URL gains sort=largest and the list is requested again
|
||||
And selecting "All" removes filter from the URL, "Newest" removes sort, and the grid icon removes view
|
||||
|
||||
Scenario: The disabled Duplicates tab does nothing
|
||||
Given the Default state has loaded
|
||||
When the user clicks or focuses the "Duplicates 2" tab
|
||||
Then the tab does not become selected, the URL does not change and no request is sent
|
||||
|
||||
Scenario: Loading, skeleton cards after 150 ms
|
||||
Given GET /v1/media/ has not yet resolved
|
||||
When the user navigates to /media
|
||||
@@ -195,7 +183,7 @@ Feature: Media page, part 1: browse and manage
|
||||
Given GET /v1/media/ resolves 200 with zero items and counts { all: 0, unused: 0, duplicates: 0 } and no q
|
||||
When the user navigates to /media
|
||||
Then the toolbar and the tabs are hidden
|
||||
And one panel shows the images icon, title "No images yet", text "Upload your first image. You can use it in any article.", a primary "Upload an image" button (disabled in this slice), and under it "PNG, JPEG, WEBP, SVG · max 10 MB each"
|
||||
And one panel shows the images icon, title "No images yet", text "Upload your first image. You can use it in any article.", a primary "Upload an image" button, and under it "PNG, JPEG, WEBP, SVG · max 10 MB each"
|
||||
|
||||
Scenario: No results, search and filter
|
||||
Given the Default state has loaded
|
||||
@@ -216,8 +204,7 @@ Feature: Media page, part 1: browse and manage
|
||||
When the user presses Enter on a card
|
||||
Then the detail panel opens for that image
|
||||
When the user presses Space on a card
|
||||
Then nothing happens and no panel opens (Space is selection, which is slice B2)
|
||||
And no checkbox is shown on hover or on focus in this slice
|
||||
Then the card's selection toggles and no panel opens
|
||||
|
||||
Scenario: Detail panel, in use
|
||||
Given the Default state has loaded and GET /v1/media/1/usage resolves 200 with [{ id: 10, title: "How we price a bathroom renovation", published: true }, { id: 11, title: "Winter checklist for timber windows", published: false }]
|
||||
@@ -239,7 +226,7 @@ Feature: Media page, part 1: browse and manage
|
||||
Given the Default state has loaded, GET /v1/media/?hash=<sha256 of id 12> resolves 200 with ids 12, 5 and 6, and GET /v1/media/12/usage resolves 200
|
||||
When the user activates the "floorboards.jpg" card
|
||||
Then an info alert reads "Identical to 2 other images" over "floorboards-copy.jpg and IMG_2208.jpg have the same content." with an action "Review duplicates" (icon arrow-right)
|
||||
And the action is rendered disabled in this slice, because the Duplicates view ships in slice B2
|
||||
And the action is enabled and selects the Duplicates tab
|
||||
|
||||
Scenario: The Used in count in list view opens the panel
|
||||
Given the Default state has loaded with ?view=list at md width or more
|
||||
|
||||
+13
-22
@@ -6,7 +6,7 @@ SPA only. The media API already exists (slice A), so there is no HTTP-level Go t
|
||||
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
|
||||
out of scope: its states are listed under "Pending, slice B2" and are never reported as passing.
|
||||
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}".
|
||||
|
||||
@@ -29,26 +29,26 @@ Logic and copy:
|
||||
"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 and ?filter=duplicates fall back to defaults;
|
||||
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, "12 images" in the Select all row, breadcrumb
|
||||
"Media", no checkbox in the DOM.
|
||||
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. **Disabled Duplicates tab** - aria-disabled, click and focus change nothing, no request.
|
||||
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" rendered disabled, the footnote "PNG, JPEG, WEBP, SVG · max 10 MB each".
|
||||
"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 opens nothing; arrow keys
|
||||
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
|
||||
@@ -57,7 +57,7 @@ Logic and copy:
|
||||
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 disabled.
|
||||
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,
|
||||
@@ -87,8 +87,7 @@ Logic and 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 disabled and inert in this slice (also
|
||||
the mobile pinned 48 px variant).
|
||||
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)
|
||||
|
||||
@@ -105,10 +104,7 @@ icon, the RenewCMS mark on the SVG, a checkerboard behind PNG and SVG), not phot
|
||||
mocked image URLs must render the same way, or the 10 % gate fails for a reason unrelated to
|
||||
the code.
|
||||
|
||||
Harness note: `web/visual/states.ts` gains a `skip: 'pending'` option, added by coder. It prints
|
||||
`PENDING` and counts as neither pass nor fail. `task visual:check -- media` still exits non-zero
|
||||
when a B1 state has no setup, when a `/v1` call is unmocked, when sizes mismatch or when a capture
|
||||
exceeds its budget. Every B2 state carries `skip: 'pending'` and is never reported as passing.
|
||||
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):
|
||||
|
||||
@@ -118,9 +114,8 @@ Setups for this slice (name in screens/media.md, then what must be drawn):
|
||||
- [ ] 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 not
|
||||
drawn in this slice).
|
||||
- [ ] Detail panel: in use (id 1), unused (id 8), duplicate (id 12; the action rendered disabled).
|
||||
- [ ] 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
|
||||
@@ -129,11 +124,7 @@ Setups for this slice (name in screens/media.md, then what must be drawn):
|
||||
- [ ] List failed to load: alert with "Try again".
|
||||
- [ ] Offline: banner and disabled Upload.
|
||||
|
||||
Pending, slice B2 (`skip: 'pending'`, never reported as passing):
|
||||
|
||||
- Selection and bulk bar; 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
|
||||
The B2 states are listed in features/media_upload_selection_duplicates.qa.md.
|
||||
|
||||
Manual checks screenshots cannot cover:
|
||||
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
Feature: Media page, part 2: upload, selection and duplicates
|
||||
|
||||
As a verified RenewCMS owner
|
||||
I want to upload images to the library from /media, select several images to delete them
|
||||
together, and merge identical copies
|
||||
So that I can keep the library complete and free of duplicates without leaving the page
|
||||
|
||||
Background:
|
||||
Given /media is the verified route from slice B1, rendered as MediaPage inside
|
||||
DashboardShell, per docs/design/routes.md and docs/design/screens/media.md
|
||||
And this slice is SPA only: the API from slice A is used unchanged, and no Go code, use case
|
||||
or repository changes
|
||||
And this slice supersedes these B1 statements: the Upload button, the mobile pinned button and
|
||||
"Upload an image" are enabled; the Duplicates tab is enabled and ?filter=duplicates is a
|
||||
valid value; "Review duplicates" is enabled; Space on a card toggles selection; the checkbox,
|
||||
the "Select all" text and the partial state exist; the B2 states no longer carry
|
||||
skip 'pending' in web/visual/states.ts
|
||||
And the calls used are POST /v1/media/ (multipart field "file", sent with XMLHttpRequest so
|
||||
that upload progress is reported, with the jwt cookie), GET /v1/media/?hash=<sha256 hex>,
|
||||
DELETE /v1/media/{id}, POST /v1/media/merge { keep_id, merge_ids } and the list call from
|
||||
B1 with filter=duplicates; every other /v1 call goes through useApi
|
||||
And 401 and 403 on any of these calls, including the XHR upload, send the user to
|
||||
/login?redirect=/media&reason=expired and /register/pending as useApi does, with no local
|
||||
copy
|
||||
And the browser-side checks reuse validateImageFile from web/src/lib/imageUpload.ts: size is
|
||||
checked first (10 MB = 10485760 bytes is allowed, one byte more is too large), then the
|
||||
file's MIME type and its extension must both be allowed (PNG, JPEG, WEBP, SVG; .png, .jpg,
|
||||
.jpeg, .webp, .svg); a rejected file is never hashed and never sent
|
||||
And hashing lives in a shared module under web/src/lib that depends on nothing in the Media
|
||||
page, so that the editor's media picker slice reuses it: it reads the file, computes the
|
||||
SHA-256 with crypto.subtle and resolves the lowercase 64-character hex, or resolves null when
|
||||
crypto.subtle is unavailable
|
||||
And at most 2 uploads are in flight at once; files are processed in the order they were
|
||||
chosen or dropped, and hashing runs inside the file's concurrency slot while its row reads
|
||||
"Waiting"
|
||||
And uploads from this page are not cropped or resized, and each file is sent as chosen
|
||||
And toast ids are "media-bulk-deleted", "media-bulk-delete-failed", "media-merged-{keep_id}"
|
||||
and "media-not-found-{id}", and success toasts last 5 s; the bulk-delete failure toast has
|
||||
no duration and stays until retried or dismissed
|
||||
And the 12-image fixture, its counts { all: 12, unused: 2, duplicates: 2 } and its sample
|
||||
data are the ones in features/media_browse.feature; the duplicate groups are {12, 5, 6}
|
||||
(floorboards.jpg, floorboards-copy.jpg, IMG_2208.jpg, 3600 × 2400, 2.4 MB each) and
|
||||
{7, 9} (IMG_4471.jpg, stone-wall-detail.jpg, 3600 × 2400, 3.1 MB each)
|
||||
And where a board frame contradicts the design prose or the fixture, this spec follows the
|
||||
prose and the fixture, and the design is corrected by the user, with no visual exception
|
||||
|
||||
Rule: Upload entry points
|
||||
|
||||
Scenario Outline: The Upload controls open the file chooser
|
||||
Given the Default state has loaded <context>
|
||||
When the user activates "<control>"
|
||||
Then the file chooser opens, accepts several files and offers image/png, image/jpeg, image/webp and image/svg+xml
|
||||
|
||||
Examples:
|
||||
| context | control |
|
||||
| at md width or more | Upload |
|
||||
| below md width | Upload |
|
||||
| and the library has no image | Upload an image |
|
||||
|
||||
Scenario: Dropping files anywhere on the page
|
||||
Given the Default state has loaded at md width or more
|
||||
When the user drags files over the page
|
||||
Then a dashed primary-bordered area covers the content, with the upload icon, the title "Drop images to upload" and the text "PNG, JPEG, WEBP, SVG · max 10 MB each"
|
||||
And the mobile pinned Upload button is hidden while the area shows
|
||||
When the user drags the files away from the page
|
||||
Then the area disappears and nothing is uploaded
|
||||
When the user drops the files on the page
|
||||
Then the area disappears and the upload of each file starts straight away, with no crop step
|
||||
|
||||
Scenario: Dragging something that is not a file does nothing
|
||||
When the user drags selected text over the page
|
||||
Then no drop area shows
|
||||
|
||||
Rule: Browser checks, nothing is sent
|
||||
|
||||
Scenario Outline: A file that fails the checks becomes a rejected row
|
||||
Given the user chooses the file "<file>" of <size> with MIME type "<mime>"
|
||||
Then one row is added with the file-x icon, the title "<title>" and the text "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image."
|
||||
And no hash lookup and no POST /v1/media/ is sent for it
|
||||
|
||||
Examples:
|
||||
| file | size | mime | title |
|
||||
| scan.tif | 14.2 MB | image/tiff | scan.tif is 14.2 MB |
|
||||
| notes.pdf | 210 KB | application/pdf | notes.pdf isn't an image |
|
||||
| photo.png | 10.1 MB | image/png | photo.png is 10.1 MB |
|
||||
| fake.jpg | 40 KB | text/plain | fake.jpg isn't an image |
|
||||
| logo.gif | 40 KB | image/gif | logo.gif isn't an image |
|
||||
| pic.jpg | 40 KB | image/png | pic.jpg isn't an image |
|
||||
|
||||
Scenario: Every rejected file leaves the panel with "Nothing was uploaded"
|
||||
Given the user chooses scan.tif (14.2 MB), notes.pdf (210 KB) and broken.png (3.0 MB, rejected by the server with 400)
|
||||
Then the panel header reads "Nothing was uploaded" with "3 files were rejected" and a "Dismiss" button
|
||||
And the rows read "scan.tif is 14.2 MB", "notes.pdf isn't an image" and "broken.png couldn't be read" with "The server couldn't read this image. Save it again as a PNG or JPEG, then try again."
|
||||
And no summary line shows
|
||||
|
||||
Scenario: Rejected and failed rows without an added image
|
||||
Given the user chooses one rejected file and one file whose upload fails with 500
|
||||
Then the panel header reads "Nothing was uploaded" with "2 files need attention"
|
||||
And no summary line shows
|
||||
|
||||
Rule: The upload panel and its rows
|
||||
|
||||
Scenario: Files upload two at a time with progress
|
||||
Given the user chooses 6 valid images and the duplicate lookup finds nothing
|
||||
Then the panel shows above the images, below the toolbar, with the title "Uploading 6 images", "0 of 6 done" and a "Cancel all" button
|
||||
And rows 1 and 2 read "Uploading", and rows 3 to 6 read "Waiting" (each row: 40 px thumbnail, filename, size)
|
||||
When the first upload reports 1.1 MB sent of 2.6 MB
|
||||
Then its row reads "Uploading 1.1 of 2.6 MB" over a 4 px progress bar filled to 42 % and shows a cancel x
|
||||
When the first upload resolves 201
|
||||
Then its row reads "Added to your library" with a circle-check icon, the header reads "1 of 6 done", and row 3 starts uploading
|
||||
And at no time are more than 2 requests to POST /v1/media/ in flight
|
||||
|
||||
Scenario: The panel header counts settled rows
|
||||
Given a panel with rows Added, Duplicate, Rejected, Failed, Uploading and Waiting
|
||||
Then the header reads "4 of 6 done"
|
||||
|
||||
Scenario: Singular forms
|
||||
Given the user chooses 1 valid image
|
||||
Then the panel title reads "Uploading 1 image"
|
||||
|
||||
Scenario: New files added while the panel is open
|
||||
Given the panel is showing rows
|
||||
When the user chooses or drops more files
|
||||
Then their rows are appended and the header counts cover all rows
|
||||
|
||||
Scenario: A new upload refreshes the list in the background
|
||||
Given an upload resolves 201
|
||||
Then the list is requested again with no skeleton, so the new image shows and the counts update
|
||||
|
||||
Scenario: A row whose content already exists
|
||||
Given the user chooses "floorboards-2.jpg" (2.4 MB, valid)
|
||||
When the SHA-256 of the file is computed
|
||||
Then GET /v1/media/?hash=<that hex> is sent before any POST
|
||||
And when it resolves 200 with the image "floorboards.jpg" (id 12, uploaded 14 Jun 2026 by Inès Duarte)
|
||||
Then the row shows "Already in your library", the text `Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.`, and the buttons "Use existing" and "Upload anyway"
|
||||
And nothing is sent with POST /v1/media/ for it, and its concurrency slot is released
|
||||
|
||||
Scenario: Several existing matches name the oldest one
|
||||
Given the lookup matches several images
|
||||
Then the row names the oldest match
|
||||
|
||||
Scenario: Use existing selects the existing image
|
||||
Given the row for "floorboards-2.jpg" is showing "Already in your library" for id 12
|
||||
When the user activates "Use existing"
|
||||
Then the row is dropped from the panel
|
||||
And the image "floorboards.jpg" is selected, the bulk bar reads "1 selected", and the page announces "floorboards.jpg selected, 1 selected"
|
||||
|
||||
Scenario: Use existing when the image is not in the loaded list
|
||||
Given the existing image is not in the loaded list because of the search, the tab or the 100 limit
|
||||
When the user activates "Use existing"
|
||||
Then the search and the tab reset to All and the image is selected
|
||||
And if it is still not loaded the row is dropped without selecting
|
||||
|
||||
Scenario: Upload anyway stores a second copy
|
||||
Given the row for "floorboards-2.jpg" is showing "Already in your library"
|
||||
When the user activates "Upload anyway"
|
||||
Then the file is sent with POST /v1/media/ and the row follows the normal upload states
|
||||
And when it resolves 201 the list is reloaded and the Duplicates tab count reflects the new copy
|
||||
|
||||
Scenario Outline: The duplicate check is skipped when it cannot run
|
||||
Given the user chooses a valid image and <condition>
|
||||
Then the file is sent with POST /v1/media/ without waiting for the answer to a lookup
|
||||
And any duplicate then shows up under Duplicates
|
||||
|
||||
Examples:
|
||||
| condition |
|
||||
| crypto.subtle is undefined (an insecure context) |
|
||||
| GET /v1/media/?hash= fails with 500 or never reaches the server |
|
||||
|
||||
Scenario Outline: Server answers for an upload that passed the checks
|
||||
Given a valid image "<file>" of <size> is uploading
|
||||
When POST /v1/media/ resolves <status>
|
||||
Then the row reads <row>
|
||||
|
||||
Examples:
|
||||
| file | size | status | row |
|
||||
| roof.png | 1.4 MB | 201 | circle-check "Added to your library" |
|
||||
| scan.png | 9.9 MB | 413 | file-x "scan.png is 9.9 MB" over "Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image." |
|
||||
| notes.png | 210 KB | 415 | file-x "notes.png isn't an image" over the same text |
|
||||
| broken.png | 3.0 MB | 400 | file-x "broken.png couldn't be read" over "The server couldn't read this image. Save it again as a PNG or JPEG, then try again." |
|
||||
| roof.png | 1.4 MB | 500 | circle-x "Upload failed" over "The server didn't accept the file. Nothing was added to your library." and a "Try again" button (icon rotate-cw) |
|
||||
| roof.png | 1.4 MB | no answer (network) | the same Failed row |
|
||||
|
||||
Scenario: Try again retries one file
|
||||
Given a row is "Upload failed"
|
||||
When the user activates "Try again"
|
||||
Then only that file is sent again with POST /v1/media/ (no new duplicate lookup), and the row follows the normal upload states
|
||||
|
||||
Scenario: Cancelling one upload or all of them
|
||||
Given several files are uploading or waiting
|
||||
When the user activates the cancel x on an uploading row
|
||||
Then that request is aborted and the row is removed, and the next waiting file starts
|
||||
When the user activates "Cancel all"
|
||||
Then every request in flight is aborted and every waiting row is removed, Added, rejected and failed rows stay, and when no row is left the panel closes
|
||||
|
||||
Scenario: The panel settles with a summary
|
||||
Given 5 files settled as 2 Added, 1 Already in your library (still undecided), 1 rejected and 1 failed
|
||||
Then the header reads "Upload finished" with "5 images" and a "Dismiss" button in place of "Cancel all"
|
||||
And a summary line reads "2 uploaded · 1 already in your library · 2 need attention"
|
||||
And the page announces the summary when the last row settles
|
||||
When the user activates "Dismiss"
|
||||
Then the panel and its rows disappear, including rejected and failed rows, which stay until then
|
||||
|
||||
Scenario Outline: The summary line counts each outcome and omits empty parts
|
||||
Given the panel has settled with <uploaded> added, <dup> already in the library, <attention> rejected or failed
|
||||
Then the summary line reads "<line>"
|
||||
|
||||
Examples:
|
||||
| uploaded | dup | attention | line |
|
||||
| 2 | 1 | 2 | 2 uploaded · 1 already in your library · 2 need attention |
|
||||
| 3 | 0 | 0 | 3 uploaded |
|
||||
| 0 | 1 | 0 | 1 already in your library |
|
||||
| 1 | 0 | 1 | 1 uploaded · 1 need attention |
|
||||
|
||||
Rule: Too many uploads (429)
|
||||
|
||||
Scenario: The server rate limits an upload
|
||||
Given a file is uploading and files are waiting
|
||||
When POST /v1/media/ resolves 429 with the header Retry-After: 42
|
||||
Then an info alert with the timer icon reads "Too many uploads" over "For your security, wait a moment before uploading again."
|
||||
And the Upload button reads "Try again in 0:42" with the timer icon and is disabled, and so is the mobile pinned button
|
||||
And Added rows stay in the panel
|
||||
And the rate-limited file goes back to Waiting and the queue pauses
|
||||
And the countdown is driven by useRetryAfter and ticks once per second
|
||||
When the countdown reaches 0:00
|
||||
Then the alert disappears, the Upload button reads "Upload" and is enabled again
|
||||
And the paused file and the waiting files resume by themselves
|
||||
|
||||
Scenario: A missing Retry-After counts as 60 seconds
|
||||
When POST /v1/media/ resolves 429 without a readable Retry-After header
|
||||
Then the countdown starts at 1:00
|
||||
|
||||
Scenario: Dropping or choosing files while rate limited
|
||||
Given the countdown is running
|
||||
When the user drops files on the page
|
||||
Then nothing is uploaded and the drop area does not show
|
||||
|
||||
Rule: Offline
|
||||
|
||||
Scenario: Upload, bulk delete and merge need a connection
|
||||
Given the Default state has loaded and navigator.onLine becomes false
|
||||
Then the offline banner from B1 shows
|
||||
And "Upload" (header, mobile pinned button and "Upload an image"), "Delete" in the bulk bar and every "Merge into this one" are disabled, and dropping files does nothing
|
||||
And browsing, selecting and "Clear" still work
|
||||
When navigator.onLine becomes true
|
||||
Then they are enabled again
|
||||
|
||||
Rule: Selection
|
||||
|
||||
Scenario: Selecting images
|
||||
Given the Default state has loaded at md width or more
|
||||
Then no checkbox is shown on the cards
|
||||
When the pointer rests on a card, or a card takes keyboard focus
|
||||
Then its checkbox shows at the top left (36 px)
|
||||
When the user ticks the checkbox of "north-wall.jpg" (or presses Space on the card)
|
||||
Then the card takes the sand fill (--rc-sand) with a primary outline, and every card now shows its checkbox
|
||||
And the page announces "north-wall.jpg selected, 1 selected"
|
||||
And the keyboard path is Space on the card: the checkbox itself is not an extra tab stop
|
||||
And Enter still opens the detail panel, and ticking a checkbox does not
|
||||
|
||||
Scenario: Unselecting announces the new count
|
||||
Given 3 images are selected
|
||||
When the user unselects "north-wall.jpg"
|
||||
Then the page announces "north-wall.jpg unselected, 2 selected"
|
||||
And when the last one is unselected it announces "Selection cleared"
|
||||
|
||||
Scenario: Selection on mobile
|
||||
Given the Default state has loaded below md width
|
||||
Then every card shows a 44 px checkbox, and so does every row of the card list in list view
|
||||
And tapping the checkbox toggles the selection, and tapping the card opens the detail panel
|
||||
|
||||
Scenario: Select all and the partial state
|
||||
Given the Default state has loaded
|
||||
Then the row above the grid reads "Select all" and "12 images", with an unchecked checkbox
|
||||
When 3 images are selected
|
||||
Then that checkbox shows the partial state
|
||||
When the user activates it while it is partial
|
||||
Then all 12 loaded images are selected and it shows checked
|
||||
When the user activates it while it is checked
|
||||
Then the selection is emptied
|
||||
And in list view at md width or more the table header checkbox does the same; the mobile card list has no "Select all" row and is selected through its 44 px row checkboxes
|
||||
|
||||
Scenario: Escape and Clear empty the selection
|
||||
Given 3 images are selected and the detail panel is closed
|
||||
When the user presses Escape, or activates "Clear" in the bulk bar
|
||||
Then the selection is emptied and the bulk bar disappears
|
||||
Given the detail panel is open over a selection
|
||||
When the user presses Escape
|
||||
Then only the panel closes and the selection stays
|
||||
|
||||
Scenario: The selection follows the query
|
||||
Given 3 images are selected
|
||||
When the user changes the search text, the tab or the sort
|
||||
Then the selection is cleared
|
||||
When the user switches between grid and list
|
||||
Then the selection is kept
|
||||
|
||||
Scenario: The bulk bar
|
||||
Given the images "north-wall.jpg", "kitchen-after.webp" and "window-frame.jpg" are selected
|
||||
Then a sticky bar sits at the bottom: 400 px wide and 24 px above the bottom edge, centred over the content area on desktop; full width with 16 px side margins and 16 px above the bottom edge on mobile
|
||||
And it has the inverted fill, the text "3 selected", a danger "Delete" button (icon trash-2) and an inverted "Clear" button
|
||||
And on mobile it replaces the pinned Upload button
|
||||
And toasts sit above it on mobile
|
||||
|
||||
Rule: Bulk delete
|
||||
|
||||
Scenario: Bulk delete, confirm
|
||||
Given "north-wall.jpg" (used in 2), "kitchen-after.webp" (used in 3) and "window-frame.jpg" (unused) are selected
|
||||
When the user activates "Delete"
|
||||
Then a dialog opens titled "Delete 3 images?" with the description "2 of them are used in 5 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone."
|
||||
And the body has one bordered row per selected image: the filename and the badge "Used in 2", "Used in 3" or "Unused"
|
||||
And the buttons read "Cancel" first, then a danger "Delete 3 images"
|
||||
And no usage request is sent
|
||||
|
||||
Scenario: Bulk delete, singular
|
||||
Given one image is selected
|
||||
When the user activates "Delete"
|
||||
Then the dialog is titled "Delete 1 image?" and the confirm button reads "Delete 1 image"
|
||||
|
||||
Scenario: Bulk delete, in progress
|
||||
Given the bulk delete dialog is open for 3 images
|
||||
When the user activates "Delete 3 images"
|
||||
Then one DELETE /v1/media/{id} is sent for each of the 3 images
|
||||
And both buttons are disabled, the confirm button reads "Deleting…" with a loader-circle, and Cancel, Escape and a backdrop click do not close the dialog
|
||||
|
||||
Scenario: Bulk delete, done
|
||||
Given a bulk delete of ids 1, 2 and 8 is in flight
|
||||
When every DELETE resolves 204
|
||||
Then the dialog closes, the 3 cards leave the grid and the selection is empty
|
||||
And focus moves to the card now at the position of the first deleted card, or to the page heading when no card remains
|
||||
And a success toast appears: icon circle-check, id "media-bulk-deleted", title "3 images deleted", text "They're gone from your library."
|
||||
And the list is then requested again in the background, with no skeleton
|
||||
|
||||
Scenario: Bulk delete, partly failed
|
||||
Given a bulk delete of ids 1, 2 and 8 is in flight
|
||||
When DELETE /v1/media/1 and DELETE /v1/media/2 resolve 204 and DELETE /v1/media/8 resolves 500
|
||||
Then the dialog closes, the cards for ids 1 and 2 leave the grid, and "window-frame.jpg" stays in the grid and stays selected, so the bulk bar reads "1 selected"
|
||||
And an error toast appears and stays until retried or dismissed: icon circle-x, id "media-bulk-delete-failed", title "Couldn't delete 1 image", text "2 images were deleted. window-frame.jpg is still in your library. Try again." and a "Retry" button (icon rotate-cw)
|
||||
When the user activates "Retry"
|
||||
Then DELETE /v1/media/8 is sent again with no dialog, and on 204 the card leaves and the toast is replaced by a success toast
|
||||
|
||||
Scenario: Bulk delete, several failures
|
||||
Given a bulk delete of 4 images in which two fail
|
||||
Then the toast title reads "Couldn't delete 2 images" and the text names both files, "a.jpg and b.jpg are still in your library. Try again."
|
||||
|
||||
Scenario: Bulk delete, an image that no longer exists
|
||||
Given a bulk delete of 3 images is in flight
|
||||
When one DELETE resolves 404 and the others 204
|
||||
Then the 404 image leaves the list and counts as deleted, and the success toast reads "3 images deleted"
|
||||
|
||||
Scenario: Bulk delete, every request fails
|
||||
Given a bulk delete of 3 images is in flight
|
||||
When every DELETE resolves 500 or never arrives
|
||||
Then the dialog stays open with an error alert "Couldn't delete the images, so they're still there. Try again in a moment." and both buttons enabled
|
||||
|
||||
Rule: Duplicates
|
||||
|
||||
Scenario: The Duplicates tab is enabled
|
||||
Given the Default state has loaded
|
||||
When the user selects the "Duplicates 2" tab
|
||||
Then the URL gains ?filter=duplicates and the list is requested with filter=duplicates and limit=100
|
||||
And the tab is selected, and "Duplicates" carries the info-tinted count of groups
|
||||
And opening /media?filter=duplicates lands on the same view, and "Review duplicates" in the detail panel of "floorboards.jpg" is enabled and selects this tab
|
||||
And search applies, the sort menu and the view toggle show as on the other tabs, sort is sent with the request, and the view toggle has no effect on the groups view while the grid or list preference stays in the URL
|
||||
|
||||
Scenario: Duplicates, groups
|
||||
Given GET /v1/media/?filter=duplicates&sort=newest&limit=100 resolves 200 with ids 5, 6, 7, 9 and 12
|
||||
When the user opens the Duplicates tab
|
||||
Then the images are grouped by sha256 into 2 bordered cards stacked with a 16 px gap, the group with the oldest suggested copy first
|
||||
And group 1 has the header "Group 1" over "3 identical images · 2.4 MB each · 3600 × 2400", and group 2 has "Group 2" over "2 identical images · 3.1 MB each · 3600 × 2400"
|
||||
And group 1 has one column per copy, in the order "floorboards.jpg", "floorboards-copy.jpg", "IMG_2208.jpg" (most used, then oldest, then lowest id), and group 2 has "IMG_4471.jpg" then "stone-wall-detail.jpg"; below md the columns stack
|
||||
And each copy shows its thumbnail, filename, "Uploaded 14 Jun 2026 by Inès Duarte", an underlined "Used in 4 articles" that opens its detail panel, and a "Merge into this one" button (icon merge)
|
||||
And "floorboards.jpg" and "IMG_4471.jpg" carry the sand fill, a primary outline, the "Suggested" tag and a primary button, and the other copies are outlined with outline buttons
|
||||
And each group's footer reads "Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it."
|
||||
|
||||
Scenario: Group header details
|
||||
Given a group of SVG images whose uploader is unknown
|
||||
Then the header reads "2 identical images · 8 KB each · Vector" and each copy reads "Uploaded 14 Jun 2026" without "by"
|
||||
|
||||
Scenario Outline: The suggested copy
|
||||
Given a group of copies <copies>
|
||||
Then the suggested copy is <suggested>
|
||||
|
||||
Examples:
|
||||
| copies | suggested |
|
||||
| used in 4 (14 Jun), 3 (2 Sep) and 2 (30 Aug) | the copy used in 4 |
|
||||
| used in 2 (29 Aug) and 2 (21 Aug) | the copy of 21 Aug (same use, oldest) |
|
||||
| used in 0 (3 Sep) and 0 (1 Sep) | the copy of 1 Sep |
|
||||
|
||||
Scenario: Duplicates, empty
|
||||
Given GET /v1/media/?filter=duplicates resolves 200 with no item and counts.duplicates 0
|
||||
Then the Duplicates tab has no count badge
|
||||
And a panel shows the copy icon, "No duplicates", "Every image in your library is different." and a "Back to all images" button
|
||||
When the user activates "Back to all images"
|
||||
Then the tab returns to All and ?filter leaves the URL
|
||||
|
||||
Rule: Merge
|
||||
|
||||
Scenario: Merge, confirm
|
||||
Given the Duplicates tab shows group 1
|
||||
When the user activates "Merge into this one" on "floorboards.jpg"
|
||||
Then a dialog opens titled "Merge into floorboards.jpg?" with the description "Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone."
|
||||
And the body shows the kept image: its thumbnail, "floorboards.jpg" and "Kept · used in 4 articles now"
|
||||
And the buttons read "Cancel" first, then a danger "Merge 2 copies"
|
||||
|
||||
Scenario: Choosing another copy keeps that one
|
||||
When the user activates "Merge into this one" on "IMG_2208.jpg" in group 1
|
||||
Then the dialog is titled "Merge into IMG_2208.jpg?" and its article count is the sum of the used_in of "floorboards.jpg" and "floorboards-copy.jpg"
|
||||
|
||||
Scenario: Merge, singular
|
||||
Given a group of 2 copies
|
||||
Then the description reads "Articles that use the other copy will be switched to …" and the confirm button reads "Merge 1 copy"
|
||||
|
||||
Scenario: Merge, in progress
|
||||
Given the Merge dialog is open for group 1 with "floorboards.jpg" kept
|
||||
When the user activates "Merge 2 copies"
|
||||
Then POST /v1/media/merge is sent with { "keep_id": 12, "merge_ids": [5, 6] }
|
||||
And both buttons are disabled, the confirm button reads "Merging…" with a loader-circle, and Cancel, Escape and a backdrop click do not close the dialog
|
||||
|
||||
Scenario: Merge, done
|
||||
Given a merge for group 1 is in flight
|
||||
When POST /v1/media/merge resolves 200 with { "articles_updated": 5, "merged": 2 }
|
||||
Then the dialog closes and group 1 leaves the list
|
||||
And a success toast appears: icon circle-check, id "media-merged-12", title "Copies merged", text "2 copies merged. 5 articles updated."
|
||||
And the page announces the same text, and the list is requested again in the background so the counts and the Duplicate flags update
|
||||
And when it was the last group, the "No duplicates" panel shows and the tab loses its count badge
|
||||
|
||||
Scenario Outline: Merge, errors keep the dialog open or remove the image
|
||||
Given a merge for group 1 is in flight
|
||||
When POST /v1/media/merge <failure>
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| failure | result |
|
||||
| resolves with a 500 response | the dialog stays open with the error alert "Couldn't merge the copies. Nothing was changed. Try again in a moment." and both buttons enabled again |
|
||||
| never reaches the server | the same alert |
|
||||
| resolves with a 409 response | the dialog stays open with "One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.", and the list is reloaded |
|
||||
| resolves with a 404 response | the dialog closes, the info toast "That image no longer exists" (id "media-not-found-12") shows, and the list is reloaded |
|
||||
|
||||
Rule: Sessions
|
||||
|
||||
Scenario Outline: Session errors are global for the new calls
|
||||
When <call> resolves <status>
|
||||
Then the user is sent to <destination> and no local copy shows
|
||||
|
||||
Examples:
|
||||
| call | status | destination |
|
||||
| POST /v1/media/ (XHR) | 401 | /login?redirect=/media&reason=expired |
|
||||
| POST /v1/media/ (XHR) | 403 | /register/pending |
|
||||
| POST /v1/media/merge | 401 | /login?redirect=/media&reason=expired |
|
||||
| DELETE /v1/media/{id} | 403 | /register/pending |
|
||||
| GET /v1/media/?hash= | 401 | /login?redirect=/media&reason=expired |
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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.
|
||||
|
||||
1. **Hashing lib** (pure): digest of a known byte array gives the expected lowercase 64-char hex; `crypto.subtle` undefined gives null; nothing in the lib imports from the Media page.
|
||||
2. **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.
|
||||
3. **Entry points**: Upload (desktop header), the mobile pinned button and "Upload an image" each click the hidden `input[type=file]` (multiple, exact accept list).
|
||||
4. **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.
|
||||
5. **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.
|
||||
6. **Duplicate check**: GET `?hash=` precedes 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.
|
||||
7. **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=`.
|
||||
8. **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.
|
||||
9. **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.
|
||||
10. **429**: `Retry-After: 42` gives 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.
|
||||
11. **Offline**: Upload, "Upload an image", bulk "Delete" and "Merge into this one" disabled; drops ignored; Clear and selection still work; back online re-enables.
|
||||
12. **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.
|
||||
13. **Bulk bar**: copy, buttons, desktop and mobile placement classes, replaces the mobile pinned button.
|
||||
14. **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.
|
||||
15. **Duplicates tab**: enabled, URL and request (`filter=duplicates`, limit=100), 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.
|
||||
16. **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.
|
||||
17. **Global errors**: 401 and 403 from XHR upload, merge, delete and hash lookup call the same redirect handler as useApi; no local copy.
|
||||
18. **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 mocks `GET /v1/media/?hash=` by the hash of the file's bytes.
|
||||
- The drag state dispatches a synthetic `dragenter` with `dataTransfer.types` = `['Files']`.
|
||||
- The 429 state freezes timers with Playwright `page.clock` so 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).
|
||||
Reference in new issue
Block a user