Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b341a3024 | ||
|
|
74f2577ca1 | ||
|
|
1881d8f23c | ||
|
|
18d81c9d96 | ||
|
|
dac7ac15ba | ||
|
|
9e125ee4a3 | ||
|
|
989b6cc2f4 | ||
|
|
828b5f9db3 | ||
|
|
95f88d3966 | ||
|
|
9bfb5992b3 | ||
|
|
5467cdbcc8 | ||
|
|
519ab59d71 | ||
|
|
49b5552340 | ||
|
|
59cdb00f67 | ||
|
|
765abc6f3e | ||
|
|
369f38be9a | ||
|
|
e6ed2914e1 | ||
|
|
878612c22d | ||
|
|
963a0f5156 | ||
|
|
27c2cf5e87 | ||
|
|
4e5c810f96 | ||
|
|
3e613adba9 | ||
|
|
fcf6445de0 | ||
|
|
0f3559bedf | ||
|
|
23887553bf | ||
|
|
807cb32f58 | ||
|
|
afe3733b69 | ||
|
|
6fa88fb2f6 | ||
|
|
6fd022cc70 | ||
|
|
9b9a07ccef | ||
|
|
305c7e1441 | ||
|
|
8d0260acef | ||
|
|
52c6efcbe4 | ||
|
|
9a7bdb2f09 | ||
|
|
f00b0232a0 | ||
|
|
7f1b5b64bd | ||
|
|
f00933ee16 | ||
|
|
beaba9ae84 | ||
|
|
ab47d7529b | ||
|
|
b84e6ea111 | ||
|
|
fe5a0017a9 | ||
|
|
d24f3f989f | ||
|
|
32cc071d07 | ||
|
|
c4ac90e9ec | ||
|
|
ed392e77e2 | ||
|
|
3e20c533fa | ||
|
|
d25c2cb4f2 | ||
|
|
f3366a13f8 | ||
|
|
de6af76064 |
+198
-11
@@ -627,8 +627,9 @@ Recorded shell deviations:
|
||||
`a saved view row matches the concept's` saves one view in both documents and
|
||||
compares the rows; `tests/browser/workspace-saved-views.spec.mjs` covers saving,
|
||||
reopening, the defaults that replace rejected values, and failed reads and saves.
|
||||
- **D50**: no "Needs review" row in SAVED VIEWS until the tag-review slice (maintainer
|
||||
ruling, 2026-10-21). The comparison maps only that row out.
|
||||
- **D50** (retired by #249 PR F, ruling R5): SAVED VIEWS now lists the concept's
|
||||
"Needs review" row after Favorites, with its count; the comparison maps nothing out
|
||||
for it (only the concept's sample count "3" reads as the fixture's "0").
|
||||
- **D51** (maintainer ruling, 2026-10-21, beyond the concept): SAVED VIEWS starts with
|
||||
the concept's Favorites row (star icon, name, count). Its count is the Favorites
|
||||
listing's own `limit=1` total (`/images/output?mode=workspace&favorites=1`), follows
|
||||
@@ -1880,6 +1881,187 @@ npm run --prefix tools/vendor test:workflows -- inspect-concept.spec.mjs workspa
|
||||
npm run --prefix tools/vendor test:comfyui -- inspect-sender.spec.mjs
|
||||
```
|
||||
|
||||
### Needs review page and recorded deviations (#249 PR F)
|
||||
|
||||
`#/review` is the concept's Metadata review page (app.js `renderReview` a749–760) over
|
||||
the review routes (`web/js/workspace/review.mjs`, `review-state.mjs`): breadcrumb and
|
||||
document title "Review" (the concept's), heading "Metadata review" with its line and
|
||||
**Open activity** (`#/activity`), the `.notice` "N images need a decision" with the
|
||||
concept's sentence, then `.gallery-layout`: the queue's concept media cards
|
||||
(`image-card.mjs`, shared with Images; the image opens the shared viewer, Info selects
|
||||
the item, Tag opens the shared Tag image dialog with the review scope note) and
|
||||
`section.surface` "Resolve the association" (filename, Tag image, Sampler context,
|
||||
the chosen path's prompt blocks, Leave for later and primary Apply association). The
|
||||
first item is selected on load, as the concept's first image is. Sampler context lists
|
||||
"Sampler <id> · <positive> / <negative>" per candidate (the server's label), disables
|
||||
unusable ones, preselects the first workable one and ends with "Leave unresolved"
|
||||
(rulings R2/R3); "Leave unresolved" shows the ambiguous status in both blocks. Apply
|
||||
takes a fresh capture (`observe`) and posts the decision; Leave unresolved on a file
|
||||
under no current root posts without a capture. Both actions return to Images (R7); a
|
||||
recorded decision re-reads the row's count, as does every metadata scan that ends. A
|
||||
refused decision keeps the selection and shows the server's sentence (the
|
||||
association conflict's explanation included) as `p.warning-note` in the panel; a
|
||||
restored storage or a changed queue reads the queue again; a request with no answer
|
||||
keeps its capture and body, and the next Apply of the same choice resends it once.
|
||||
The SAVED VIEWS row "Needs review" (review icon) follows Favorites with the exact
|
||||
pending count (`GET /prompt_manager/review/items?limit=1`, its `total`).
|
||||
|
||||
- **D86** (plan D63, ruling R6): an empty queue shows only the notice, "No images need a
|
||||
decision" and "Ambiguous images are added when a metadata scan or a new generation
|
||||
finds more than one sampler path."
|
||||
- **D87** (plan D64): an ended metadata scan whose `scope.review_items` is above 0
|
||||
carries the concept's **Review metadata** (app.js a738 `go-review`): in its Activity
|
||||
item after the item's line, and in its job view's heading after Download report. The
|
||||
item's chip still names its state (D54), where the concept's says "Needs attention".
|
||||
- **D119**: the queue cards have no selection checkbox and
|
||||
no star (the page has no selection bar and the queue carries no favorite state); a
|
||||
file under no current root has an empty, `aria-disabled` image button and Tag; more
|
||||
than 40 items add the Images page's `.infinite-continuation` ("40 of 45 images",
|
||||
Load more images) after the layout; loading and a failed read are the page's status
|
||||
line (with Retry); a limited or unavailable file and the decision states are text in
|
||||
the panel's `p.warning-note`; the toasts "Association accepted." and "Left
|
||||
unresolved." replace the concept's prototype toast. The concept's "Sample review
|
||||
queue" chip and "sample" wording are prototype copy (D29). When a re-read (below)
|
||||
finds the open item gone, the next one opens with "<file> was decided elsewhere, so
|
||||
the next image is open."; a failed re-read keeps the queue and says "The review queue
|
||||
could not be read again. Reload the page to see the latest images." A queue card's
|
||||
title is its file name and its line the extension only (an undecided item names no
|
||||
prompt, and D11 omits the size and seed a queue item does not carry); its image's alt
|
||||
is the file name; the panel's Copy and Send buttons carry no prompt title in their
|
||||
names; the Tag image dialog opened from the queue heads with the file name. An
|
||||
unavailable file's empty media button keeps the concept's 1.33 media area, so its
|
||||
card lines up with the others. A long file name in the panel's `p.small` wraps inside
|
||||
the panel (`overflow-wrap: anywhere`), where the concept's short sample name never
|
||||
overflows. A queue card shows the Images listing's thumbnail when the server knows one
|
||||
(`thumbnail_url`, the same URL the Images page uses), else the original; a re-read
|
||||
rebuilds a card whose media, target or availability changed (a root came or went), so
|
||||
Open and Tag follow it. When an unanswered decision's item leaves the queue in a
|
||||
re-read, the decision was recorded: the page finishes as a recorded decision does (its
|
||||
toast, back to Images), not with "decided elsewhere".
|
||||
- **D120** (the concept has no live-update state): while the
|
||||
change stream is down, the heading carries the quiet status line "Live updates are
|
||||
paused: changes made elsewhere appear once the connection returns." (`p.small
|
||||
.review-live`, `role="status"`); the stream's next `ready` hides it.
|
||||
|
||||
**Live queue (F2).** An open Needs review page holds one server-sent event stream,
|
||||
`GET /prompt_manager/review/events` (`py/api/review_events.py`): `event: ready` when it
|
||||
opens, then `event: change` after each committed queue change (an item the scan or the
|
||||
monitor recorded, a spelling refresh, a decision from any tab; `database/review_changes.py`
|
||||
notifies after the commit, from whichever thread wrote it) and after a restore. `change`
|
||||
carries no data. `ready` carries the queue's version `{library_epoch, review_revision}`,
|
||||
read after the stream subscribed, and every list read answers the same pair
|
||||
(`review_revision` is the review index revision, which every queue change moves; a
|
||||
restore moves the epoch). **One read per visit (F3):** the page re-reads on `ready` only
|
||||
when the queue it shows is older than that version (an unknown version, `{}` when it
|
||||
cannot be read, re-reads); a `ready` heard while the first read runs is compared once that
|
||||
read answers. The test server counts a stream as open only once it has sent `ready`, so a
|
||||
browser test that changes the queue after that count is a `change`, never part of the
|
||||
version. The page re-reads on every `change`, keeping the open item, its choice and an
|
||||
unanswered decision; re-reads asked for while one runs collapse into one more. A change
|
||||
heard while a decision is in flight is owed: a refused or unanswered decision reads it
|
||||
once the panel is idle again (a recorded one leaves the page). Load more asked for during
|
||||
a re-read waits for it and continues after the queue it read; a re-read started during
|
||||
Load more takes that click over and reads one page deeper, and a failed re-read leaves
|
||||
Load more clickable. Apply fences on the epoch of the detail it decides: an observe under
|
||||
another storage releases its capture and reads the queue and the item again, and any
|
||||
re-read under another epoch reads the open item again. **Every re-read keeps the loaded depth (F3):** it
|
||||
reads page after page (40 each, the route's limit) until it holds as many items as Load
|
||||
more had shown, so the cards stay and the scroll position does not move; a restore met
|
||||
after the first page reads once more from the first page. The row's count follows the
|
||||
total the page shows (no count read per change). Nothing polls: a `: keepalive` comment
|
||||
every 25 s only lets the server drop a closed tab's subscription. The server sends the
|
||||
first change at once and collapses the notices of the next 0.4 s into one trailing
|
||||
`change` (`COALESCE_SECONDS`), so a busy scan costs an open page one re-read per 0.4 s at
|
||||
most. Shutdown (`on_shutdown`) ends every stream and answers a later one 503. A
|
||||
reconnect the browser gives up on (a 503, or a proxy's error while ComfyUI restarts) is
|
||||
opened again by the page after 3 s, doubling to 30 s, with D120's line shown while
|
||||
the stream is down; its `ready` re-syncs the queue. The stream lives only while the tab is
|
||||
visible: a hidden tab closes it and a visible one opens it again, and that `ready` reads
|
||||
only when the queue changed meanwhile. A duplicate recording and a refused decision change
|
||||
nothing and send nothing. Residual: each visible Needs review page holds one HTTP/1.1
|
||||
connection (browsers allow six per origin, shared with ComfyUI's editor), so several
|
||||
side-by-side visible windows on the page, during a metadata scan's job stream, can still
|
||||
queue the origin's other requests; a re-read of a deep queue costs one request per 40
|
||||
items shown.
|
||||
|
||||
Pinned by `tests/test_api_review_events.py`, `tests/js/workspace-review-state.test.js`
|
||||
("refresh …", "a refresh keeps the loaded depth …", "ready naming …") and
|
||||
`tests/browser/workspace-review.spec.mjs` ("an open page shows …", "a visit reads its
|
||||
queue once …", "a live re-read keeps the loaded depth and the scroll position", "a stream
|
||||
the browser gives up on …", "a hidden tab closes its stream …", "a re-read refreshes a
|
||||
card …"); the controller's owed, Load more and epoch cases are "I1: …", "I2: …", "M3:
|
||||
…", "M5: …" and "M7: …" in `workspace-review-state.test.js`, and the coalescing is "a
|
||||
burst of changes …" in `test_api_review_events.py`.
|
||||
Leaving the page mid-decision releases that decision's capture (the server may never see
|
||||
the request), and leaving during the observe releases the capture it then answers
|
||||
("leaving during the observe …").
|
||||
**Concept comparison (F3).** `tests/browser/review-concept.spec.mjs` measures the whole
|
||||
page (`reviewInventory` in `tests/browser/tagging-canonical-comparison.mjs`: every shown
|
||||
box below `main` with its place, fonts, edges and layout, a leaf's text, a button's name,
|
||||
a select's options) in the concept at `#review` and in production on the fixture's
|
||||
concept-shaped queue (`POST /__test__/review/reset {items: 3, concept: true}`: the
|
||||
concept's first three image names and sizes, and the concept's first prompt on the open
|
||||
item), and `mapConceptReview` applies only: D29 (no "Sample review queue" chip, no
|
||||
"sample" in the panel heading and Apply), D119 (above), D3 (the grid's
|
||||
`position: relative`), production's hooks and accessible names (`review-queue`,
|
||||
`review-panel`, the list roles, the region names) and sample data (the candidate options
|
||||
except Leave unresolved; widths and heights that follow from the changed copy, moved by
|
||||
exactly that much). It then matches at 1600×1100 and 390×844 in both themes on all four
|
||||
projects; the gallery layout follows the concept at 1600, 1200, 900 and 390px (310px,
|
||||
275px with a 15px gap, 280px, one column); the SAVED VIEWS rows match with Needs review
|
||||
after Favorites; the queue's Tag image dialog matches the concept's needs-review dialog
|
||||
(`needs-review-tagging.png`) under the C4 mapping; and the comparison is shown to notice
|
||||
a dropped notice border and reordered actions. The paired captures (populated queue in
|
||||
both themes and widths, conflict, limited item, unavailable file, empty queue) are test
|
||||
artifacts; no screenshot baseline is added. The comparison found one gap, the collapsed
|
||||
media area of an unavailable file's card, fixed above (`workspace-review.spec.mjs` "a
|
||||
limited item and an unavailable file …" pins it).
|
||||
|
||||
- **D3** and **D20** apply to the queue grid (its cards stay in the grid's flow; it is
|
||||
not virtualized) and to the panel's prompt blocks (their mount point is transparent).
|
||||
|
||||
Tests: `tests/browser/review-concept.spec.mjs` (concept comparison),
|
||||
`tests/js/workspace-review-state.test.js` (controller),
|
||||
`tests/js/workspace-state.test.js` (route and count),
|
||||
`tests/browser/workspace-review.spec.mjs` (queue, count, apply, leave, Tag, empty
|
||||
queue, Load more, Activity's Review metadata; items recorded by the real recorder in
|
||||
`tests/browser/workspace_review_fixture.py`), and the SAVED VIEWS rows in
|
||||
`shell-concept.spec.mjs` and `workspace-saved-views.spec.mjs`.
|
||||
|
||||
### Native image tagging and Needs review (#249 PRs A–F)
|
||||
|
||||
One place for the whole image-tagging slice; the details live in the sections named.
|
||||
|
||||
- **Routes.** Tagging: `POST /prompt_manager/image-annotations/observe`, `GET`/`DELETE
|
||||
/prompt_manager/image-annotations/capture/<token>`, `POST
|
||||
/prompt_manager/image-annotations/suggest`, `PUT /prompt_manager/image-annotations/tags`
|
||||
("Tag image dialog and recorded deviations"). Needs review: `GET
|
||||
/prompt_manager/review/items?limit=1..40&cursor=` (answers `library_epoch`,
|
||||
`review_revision`, `total`), `GET /prompt_manager/review/items/<id>`, `POST
|
||||
/prompt_manager/review/items/<id>/decision` and the change stream `GET
|
||||
/prompt_manager/review/events` ("Needs review decisions and queue reads", "Needs review
|
||||
page and recorded deviations"). Every legacy route keeps its payload; the only legacy
|
||||
change is `409 model-busy` from the legacy Auto-tag routes while another model
|
||||
operation holds the shared gate.
|
||||
- **S1 (fingerprint-current reads, ruled 2026-10-22).** The Images Tags filter, search and
|
||||
the inspector/viewer chips use a byte record only while the file's `(st_size,
|
||||
st_mtime_ns)` equals its verified fingerprint; writes hash the bytes. Residual risk
|
||||
under "Image annotations schema".
|
||||
- **Capture limits.** 256 MiB per file, 8 completed captures, 512 MiB reserved, 2
|
||||
capture/verify workers, 900 s idle, 1800 s hard; the browser fixtures release every
|
||||
capture on reset, and leaving the Needs review page mid-decision releases its capture.
|
||||
- **The record-changing job.** Bulk Add tags and Auto-tag selected run as the
|
||||
`image-tags` Activity job (PR D); like the metadata scan it is a record-changing job, so
|
||||
a start while another runs is refused (`busy`). Needs review items come from that scan
|
||||
and from the live monitor, and a decision changes links only when compatible
|
||||
(`association-conflict` otherwise, with zero writes).
|
||||
- **No downloads.** No automated test downloads or loads a model: the browser server
|
||||
injects `TestModel` (its `download_model` raises), native routes and jobs never
|
||||
download, and every tagging spec asserts no `/autotag/download/` request.
|
||||
- **Restart.** PRs A, B, D and E and PR F (the review change stream, the queue version)
|
||||
change Python: the maintainer restarts ComfyUI once after merging. HTML, CSS and JS
|
||||
changes need only a browser refresh.
|
||||
|
||||
### Native System (#249 System)
|
||||
|
||||
`#/system/<section>` is a native workspace route rendered by `web/js/workspace/system.mjs`
|
||||
@@ -2020,8 +2202,8 @@ Recorded deviations (plan D60–D66 and D81 renumbered D92–D98 and D113; D121
|
||||
integrity" is "Not checked" until a check ran this session, then "N issues" (checks
|
||||
that found issues, even beside a check that could not run), "Passed" or "Incomplete".
|
||||
The review row is "N ambiguous image links" (the Needs review queue's pending count,
|
||||
`needs_review.pending`) with the concept's line; its Review metadata action is
|
||||
`aria-disabled` (CSS under D93) until the Needs review page ships (#288). A count
|
||||
`needs_review.pending`) with the concept's line; its Review metadata action opens
|
||||
`#/review` (the concept's `go-review`). A count
|
||||
that is not an integer reads "Ambiguous image links" / "Not available yet." (Q6).
|
||||
"Backup status" reads "No retained backup record." until verified backups exist (S8).
|
||||
The thumbnail row is "Checking thumbnails…" until its own read answers, then "No
|
||||
@@ -2492,13 +2674,18 @@ Needs review decisions and queue reads (#249, `py/api/review.py`,
|
||||
`database/review.py`, `database/review_decisions.py`,
|
||||
`tests/test_api_review.py`): `GET /prompt_manager/review/items?limit=1..40&cursor=`
|
||||
answers `{success, library_epoch, total, items, next_cursor}` with pending items
|
||||
oldest first; `total` is the exact pending count. The cursor is an HMAC keyset
|
||||
on `(created_at, id)` bound to the storage epoch and `review_index_revision`: a
|
||||
malformed one is 400, a well-formed one this process cannot verify (tampered,
|
||||
or opened before a ComfyUI restart: the secret is per process) is 409
|
||||
`review-conflict` with the current `total`, one from before a restore is 409
|
||||
`storage-changed`, one from before any queue change (a decision, a new item, a
|
||||
refreshed spelling) is 409 `review-conflict`.
|
||||
oldest first (each item adds the card's `url`, the Images listing's
|
||||
`/prompt_manager/images/serve/<path>?root=<i>&root_scope=<scope>` formatted without a
|
||||
stat, null when the file is under no current root, and `is_video` from its extension); `total` is the exact pending count. The cursor is an HMAC keyset
|
||||
on `(created_at, id)` bound to the storage epoch only (ruling M3, so "load more"
|
||||
keeps working while the monitor adds an item per generation): a malformed one
|
||||
is 400, a well-formed one this process cannot verify (tampered, or opened
|
||||
before a ComfyUI restart: the secret is per process) is 409 `review-conflict`
|
||||
with the current `total`, and one from before a restore is 409
|
||||
`storage-changed`. A decision or a new item leaves an open cursor valid: a
|
||||
decided item drops out of later pages and new items (stamped `utc_now_iso()`,
|
||||
microseconds) append after it. Residual risk: an item recorded after a wall
|
||||
clock step backwards sorts before an open cursor and shows only on reload.
|
||||
Targets are formatted from `spelled_path` against the current roots (the
|
||||
roots' real paths are the only file access); a file under no current root is
|
||||
`unavailable` with a null target. Every read and decision lists the roots and
|
||||
|
||||
@@ -91,6 +91,9 @@ try:
|
||||
if host_app is not None and hasattr(host_app, "on_cleanup"):
|
||||
host_app.on_cleanup.append(api.cleanup_history_reads)
|
||||
host_app.on_cleanup.append(api.cleanup_annotation_captures)
|
||||
if host_app is not None and hasattr(host_app, "on_shutdown"):
|
||||
# Open Needs review change streams end before the server waits on them.
|
||||
host_app.on_shutdown.append(api.close_review_events)
|
||||
|
||||
except Exception as e:
|
||||
# Log to internal logging system without console spam
|
||||
|
||||
+22
-2
@@ -31,6 +31,7 @@ from .annotation_schema import (
|
||||
)
|
||||
from .annotations import _new_id, _validate_locator, _validate_target
|
||||
from .models import normalize_image_path, utc_now_iso
|
||||
from .review_changes import notify_review_change
|
||||
from .review_schema import (
|
||||
DECISIONS_TABLE,
|
||||
ITEMS_TABLE,
|
||||
@@ -315,7 +316,9 @@ class ReviewOperations:
|
||||
refreshed = _refresh_spelling(conn, locator, spelled_path)
|
||||
if inserted or refreshed:
|
||||
conn.execute(_BUMP_REVIEW_INDEX)
|
||||
return inserted
|
||||
if inserted or refreshed:
|
||||
notify_review_change(self.model.db_path)
|
||||
return inserted
|
||||
|
||||
def refresh_review_spelling(self, locator: str, spelled_path: str) -> bool:
|
||||
"""Record ``spelled_path`` as the spelling of every item at ``locator``.
|
||||
@@ -332,7 +335,9 @@ class ReviewOperations:
|
||||
refreshed = _refresh_spelling(conn, locator, spelled_path)
|
||||
if refreshed:
|
||||
conn.execute(_BUMP_REVIEW_INDEX)
|
||||
return refreshed
|
||||
if refreshed:
|
||||
notify_review_change(self.model.db_path)
|
||||
return refreshed
|
||||
|
||||
def review_index_revision(self) -> int:
|
||||
"""The queue's revision (moved by every queue change)."""
|
||||
@@ -341,6 +346,19 @@ class ReviewOperations:
|
||||
raise sqlite3.DatabaseError("Missing review queue metadata")
|
||||
return row[0]
|
||||
|
||||
def review_queue_version(self) -> dict:
|
||||
"""``{library_epoch, review_revision}`` of the queue, from one snapshot.
|
||||
|
||||
The pair a list read answers: equal pairs mean the same queue (a
|
||||
restore moves the epoch, every queue change moves the revision).
|
||||
"""
|
||||
with self._annotation_read() as conn:
|
||||
epoch = conn.execute(_EPOCH).fetchone()
|
||||
revision = conn.execute(_REVIEW_INDEX).fetchone()
|
||||
if epoch is None or revision is None:
|
||||
raise sqlite3.DatabaseError("Missing review queue metadata")
|
||||
return {"library_epoch": epoch[0], "review_revision": revision[0]}
|
||||
|
||||
def review_items_for(self, locator: str) -> list:
|
||||
"""Every item of the file at ``locator``, oldest first, evidence parsed."""
|
||||
_validate_locator(locator)
|
||||
@@ -423,6 +441,8 @@ class ReviewOperations:
|
||||
removed += conn.execute(_SETTLE_ITEM, (item_id,)).rowcount
|
||||
if removed:
|
||||
conn.execute(_BUMP_REVIEW_INDEX)
|
||||
if removed:
|
||||
notify_review_change(self.model.db_path)
|
||||
return removed
|
||||
|
||||
def settle_untraced_review_items(self, locators) -> int:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Needs review queue change notices (#249 PR F2).
|
||||
|
||||
Every committed change to the pending queue (an item recorded by the scan or
|
||||
the monitor, a spelling refresh, a decision) calls ``notify_review_change``
|
||||
after its transaction, from whichever thread wrote it. The review event
|
||||
stream (``py/api/review_events.py``) subscribes, so an open Needs review page
|
||||
re-reads its queue when it changes instead of polling. Observers are keyed by
|
||||
the database file, like ``subscribe_history_restore``; an observer that fails
|
||||
is logged and never fails the write.
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
except ImportError:
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
_logger = get_logger("prompt_manager.database.review_changes")
|
||||
_lock = threading.Lock()
|
||||
_observers: dict[str, dict[object, Callable[[], None]]] = {}
|
||||
|
||||
|
||||
def _identity(db_path: str) -> str:
|
||||
return os.path.normcase(os.path.realpath(db_path))
|
||||
|
||||
|
||||
def subscribe_review_changes(
|
||||
db_path: str, callback: Callable[[], None]
|
||||
) -> Callable[[], None]:
|
||||
"""Call ``callback()`` after each committed queue change; return the unsubscribe."""
|
||||
identity = _identity(db_path)
|
||||
token = object()
|
||||
with _lock:
|
||||
_observers.setdefault(identity, {})[token] = callback
|
||||
|
||||
def unsubscribe() -> None:
|
||||
with _lock:
|
||||
observers = _observers.get(identity)
|
||||
if observers is not None:
|
||||
observers.pop(token, None)
|
||||
if not observers:
|
||||
_observers.pop(identity, None)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
|
||||
def observer_count(db_path: str) -> int:
|
||||
"""How many observers watch ``db_path`` (tests and diagnostics)."""
|
||||
with _lock:
|
||||
return len(_observers.get(_identity(db_path), {}))
|
||||
|
||||
|
||||
def notify_review_change(db_path: str) -> None:
|
||||
"""Tell every observer of ``db_path``; call it after the commit."""
|
||||
with _lock:
|
||||
callbacks = tuple(_observers.get(_identity(db_path), {}).values())
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback()
|
||||
except Exception:
|
||||
_logger.warning("Needs review change observer failed", exc_info=True)
|
||||
@@ -29,6 +29,7 @@ from .review import (
|
||||
dependent_images,
|
||||
same_name_rows,
|
||||
)
|
||||
from .review_changes import notify_review_change
|
||||
from .review_schema import DECISIONS_TABLE, ITEMS_TABLE, canonical_evidence
|
||||
|
||||
DECISION_KIND = "user-selection"
|
||||
@@ -119,7 +120,7 @@ class ReviewDecisionOperations:
|
||||
raise ValueError("A selected decision needs a candidate and a file")
|
||||
try:
|
||||
with self._pair_transaction() as conn:
|
||||
return self._decide_in(
|
||||
decided = self._decide_in(
|
||||
conn,
|
||||
item_id=item_id,
|
||||
expected_epoch=expected_epoch,
|
||||
@@ -135,6 +136,9 @@ class ReviewDecisionOperations:
|
||||
)
|
||||
except _Rollback as rollback:
|
||||
raise rollback.error from None
|
||||
# After the commit: open Needs review pages re-read their queue.
|
||||
notify_review_change(self.model.db_path)
|
||||
return decided
|
||||
|
||||
def _decide_in(self, conn: sqlite3.Connection, **command) -> dict:
|
||||
epoch = self._library_epoch(conn, command["expected_epoch"])
|
||||
|
||||
@@ -10,6 +10,7 @@ Split into domain-specific mixins for maintainability:
|
||||
- ImageTagJobsMixin: bulk Add tags / Auto-tag as image-tags jobs
|
||||
- ReviewRoutesMixin: the Needs review queue, item detail and decisions
|
||||
- ReviewRecordingMixin: Needs review recording for the scan and the monitor
|
||||
- ReviewEventsMixin: the Needs review change stream an open page re-reads on
|
||||
- SystemRoutesMixin: System health check, Overview and usage statistics
|
||||
"""
|
||||
|
||||
@@ -34,6 +35,7 @@ from .vocabulary import VocabularyRoutesMixin
|
||||
from .annotations import AnnotationRoutesMixin
|
||||
from .review import ReviewRoutesMixin
|
||||
from .review_recording import ReviewRecordingMixin
|
||||
from .review_events import ReviewEventsMixin
|
||||
from .saved_views import SavedViewRoutesMixin
|
||||
from .history import HistoryRoutesMixin
|
||||
from .images import ImageRoutesMixin
|
||||
@@ -270,6 +272,7 @@ class PromptManagerAPI(
|
||||
AnnotationRoutesMixin,
|
||||
ReviewRoutesMixin,
|
||||
ReviewRecordingMixin,
|
||||
ReviewEventsMixin,
|
||||
SavedViewRoutesMixin,
|
||||
HistoryRoutesMixin,
|
||||
PromptRoutesMixin,
|
||||
@@ -493,6 +496,7 @@ class PromptManagerAPI(
|
||||
self._register_vocabulary_routes(routes)
|
||||
self._register_annotation_routes(routes)
|
||||
self._register_review_routes(routes)
|
||||
self._register_review_event_routes(routes)
|
||||
self._register_saved_view_routes(routes)
|
||||
self._register_history_routes(routes)
|
||||
self._register_prompt_routes(routes)
|
||||
|
||||
+21
-8
@@ -671,6 +671,25 @@ def _iter_media_files(output_path, max_files):
|
||||
yield root_path / name
|
||||
|
||||
|
||||
def media_thumbnail_url(output_path, rel_path, root_index, *, root_scope=None):
|
||||
"""The Images listing's thumbnail URL for *rel_path* under *output_path*,
|
||||
or None when no thumbnail exists (one blocking stat). The Needs review
|
||||
queue cards use the same URL (#249 F, M10)."""
|
||||
rel_path = Path(rel_path)
|
||||
extension = rel_path.suffix.lower()
|
||||
thumb_rel = (
|
||||
f"thumbnails/{rel_path.with_suffix('').as_posix()}_thumb"
|
||||
f"{'.jpg' if extension in VIDEO_EXTENSIONS else extension}"
|
||||
)
|
||||
thumbnail_url = thumbnail_url_for(thumb_rel, Path(output_path) / thumb_rel)
|
||||
if thumbnail_url is None:
|
||||
return None
|
||||
thumbnail_url += f"&root={root_index}"
|
||||
if root_scope is not None:
|
||||
thumbnail_url += f"&root_scope={root_scope}"
|
||||
return thumbnail_url
|
||||
|
||||
|
||||
def _output_image_entry(
|
||||
media_path, output_path, root_index, public_root=None, *, root_scope=None
|
||||
):
|
||||
@@ -686,19 +705,13 @@ def _output_image_entry(
|
||||
extension = media_path.suffix.lower()
|
||||
is_video = extension in VIDEO_EXTENSIONS
|
||||
|
||||
thumb_rel = (
|
||||
f"thumbnails/{rel_path.with_suffix('').as_posix()}_thumb"
|
||||
f"{'.jpg' if is_video else extension}"
|
||||
thumbnail_url = media_thumbnail_url(
|
||||
output_path, rel_path, root_index, root_scope=root_scope
|
||||
)
|
||||
thumbnail_url = thumbnail_url_for(thumb_rel, output_path / thumb_rel)
|
||||
if thumbnail_url is not None:
|
||||
thumbnail_url += f"&root={root_index}"
|
||||
encoded_path = urllib.parse.quote(rel_path.as_posix(), safe="/")
|
||||
url = f"/prompt_manager/images/serve/{encoded_path}?root={root_index}"
|
||||
if root_scope is not None:
|
||||
url += f"&root_scope={root_scope}"
|
||||
if thumbnail_url is not None:
|
||||
thumbnail_url += f"&root_scope={root_scope}"
|
||||
|
||||
entry = {
|
||||
"id": hashlib.sha1(
|
||||
|
||||
+31
-14
@@ -1,8 +1,9 @@
|
||||
"""Needs review routes (#249 E3): the pending queue, item detail, decisions.
|
||||
|
||||
``GET /prompt_manager/review/items`` lists pending items oldest first behind
|
||||
an HMAC keyset cursor bound to the storage epoch and the review index
|
||||
revision (any decision, new item or restore makes an old cursor stale);
|
||||
an HMAC keyset cursor bound to the storage epoch only (a restore makes an
|
||||
old cursor stale; a decision or a new item does not, because the
|
||||
``(created_at, id)`` keyset stays ordered when items leave or are appended);
|
||||
``total`` is the exact pending count. ``GET .../items/{id}`` adds the
|
||||
candidates and the dependency fingerprint. ``POST .../items/{id}/decision``
|
||||
records one explicit decision: Apply links the chosen sampler's pair only
|
||||
@@ -23,10 +24,13 @@ import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .annotations import _HEX64, _TOKEN, _UUID, _capture_error, _error
|
||||
from .images import media_thumbnail_url
|
||||
from .inspection_service import VIDEO_EXTENSIONS
|
||||
from .prompts import bad_request
|
||||
|
||||
try:
|
||||
@@ -153,9 +157,9 @@ class _StaleCursor(ValueError):
|
||||
"""A well-formed cursor this process cannot verify (e.g. after a restart)."""
|
||||
|
||||
|
||||
def _encode_cursor(epoch: str, revision: int, key: tuple) -> str:
|
||||
def _encode_cursor(epoch: str, key: tuple) -> str:
|
||||
payload = json.dumps(
|
||||
{"v": 1, "e": epoch, "r": revision, "k": list(key)},
|
||||
{"v": 2, "e": epoch, "k": list(key)},
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
).encode()
|
||||
@@ -164,7 +168,7 @@ def _encode_cursor(epoch: str, revision: int, key: tuple) -> str:
|
||||
|
||||
|
||||
def _decode_cursor(token: str) -> tuple:
|
||||
"""``(epoch, revision, (created_at, id))`` of a cursor.
|
||||
"""``(epoch, (created_at, id))`` of a cursor.
|
||||
|
||||
``ValueError`` when malformed; :class:`_StaleCursor` when well-formed but
|
||||
signed by another secret (the per-process secret changes on every start,
|
||||
@@ -185,15 +189,14 @@ def _decode_cursor(token: str) -> tuple:
|
||||
data = json.loads(payload)
|
||||
key = data["k"]
|
||||
if (
|
||||
data["v"] != 1
|
||||
data["v"] != 2
|
||||
or not isinstance(data["e"], str)
|
||||
or type(data["r"]) is not int
|
||||
or not isinstance(key, list)
|
||||
or len(key) != 2
|
||||
or not all(isinstance(part, str) for part in key)
|
||||
):
|
||||
raise ValueError()
|
||||
return data["e"], data["r"], tuple(key)
|
||||
return data["e"], tuple(key)
|
||||
except _StaleCursor:
|
||||
raise
|
||||
except (ValueError, KeyError, TypeError, UnicodeError, binascii.Error):
|
||||
@@ -253,7 +256,7 @@ class ReviewRoutesMixin:
|
||||
def _review_view(self, row: dict, roots, real_roots) -> dict:
|
||||
"""The public ReviewItem of one stored row (no path, no locator)."""
|
||||
found = format_review_target(row["spelled_path"], row["locator"], real_roots)
|
||||
target = None
|
||||
target = url = thumbnail_url = None
|
||||
if found is not None:
|
||||
index, relative = found
|
||||
target = {
|
||||
@@ -261,16 +264,30 @@ class ReviewRoutesMixin:
|
||||
"root_scope": self._gallery_root_scope(roots[index], index),
|
||||
"path": relative,
|
||||
}
|
||||
# The Images listing's media URL for the same file (formatting only).
|
||||
url = (
|
||||
"/prompt_manager/images/serve/"
|
||||
f"{urllib.parse.quote(relative, safe='/')}?root={index}"
|
||||
f"&root_scope={target['root_scope']}"
|
||||
)
|
||||
# The Images listing's thumbnail, when one exists (one stat).
|
||||
thumbnail_url = media_thumbnail_url(
|
||||
roots[index], relative, index, root_scope=target["root_scope"]
|
||||
)
|
||||
filename = os.path.basename(row["spelled_path"])
|
||||
return {
|
||||
"id": row["id"],
|
||||
"state": row["state"],
|
||||
"revision": row["revision"],
|
||||
"created_at": row["created_at"],
|
||||
"filename": os.path.basename(row["spelled_path"]),
|
||||
"filename": filename,
|
||||
"target": target,
|
||||
"unavailable": target is None,
|
||||
"candidate_count": row["candidate_count"],
|
||||
"limited": row["limited"],
|
||||
"url": url,
|
||||
"thumbnail_url": thumbnail_url,
|
||||
"is_video": os.path.splitext(filename)[1].lower() in VIDEO_EXTENSIONS,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -299,26 +316,26 @@ class ReviewRoutesMixin:
|
||||
return bad_request(str(exc))
|
||||
try:
|
||||
page, views = await self._run_in_executor(
|
||||
self._review_page, limit, bound[2] if bound else None
|
||||
self._review_page, limit, bound[1] if bound else None
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("Review queue read failed")
|
||||
return _failed()
|
||||
if bound is not None and bound[0] != page["epoch"]:
|
||||
return _coded("storage-changed", library_epoch=page["epoch"])
|
||||
if bound is not None and bound[1] != page["revision"]:
|
||||
return _coded("review-conflict", total=page["total"])
|
||||
items = page["items"]
|
||||
next_cursor = None
|
||||
if page["more"] and items:
|
||||
last = items[-1]
|
||||
next_cursor = _encode_cursor(
|
||||
page["epoch"], page["revision"], (last["created_at"], last["id"])
|
||||
page["epoch"], (last["created_at"], last["id"])
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"library_epoch": page["epoch"],
|
||||
# F3: the queue version the change stream's ``ready`` names.
|
||||
"review_revision": page["revision"],
|
||||
"total": page["total"],
|
||||
"items": views,
|
||||
"next_cursor": next_cursor,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Needs review change stream (#249 PR F2).
|
||||
|
||||
``GET /prompt_manager/review/events`` is a server-sent event stream for an
|
||||
open Needs review page: ``event: ready`` when it opens, then
|
||||
``event: change`` after each committed queue change (an item the scan or the
|
||||
monitor recorded, a decision from any tab, a spelling refresh) and after a
|
||||
restore. The first notice is sent at once; notices in the next
|
||||
``COALESCE_SECONDS`` collapse into one trailing ``change`` (M9: a busy scan
|
||||
records file after file, and each event costs every open page a re-read).
|
||||
The page re-reads its queue on each ``change``; nothing polls. ``change``
|
||||
carries no data (``{}``). ``ready`` carries the queue's version
|
||||
``{library_epoch, review_revision}``, read after the stream subscribed, the
|
||||
same pair a list read answers: a page whose first read already answered that
|
||||
version skips the re-read (F3, one read per visit), and any later change is a
|
||||
``change`` event. Nothing else about the queue is pushed.
|
||||
|
||||
A ``: keepalive`` comment every ``HEARTBEAT_SECONDS`` lets the server notice
|
||||
a closed tab (its write fails) and drop the subscription; clients ignore
|
||||
comments. Shutdown (``close_review_events``, an ``on_shutdown`` hook) ends
|
||||
every stream, and a stream asked for afterwards is a 503.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ...database.execution_history import subscribe_history_restore
|
||||
from ...database.review_changes import subscribe_review_changes
|
||||
except ImportError:
|
||||
from database.execution_history import subscribe_history_restore
|
||||
from database.review_changes import subscribe_review_changes
|
||||
|
||||
EVENTS_URL = "/prompt_manager/review/events"
|
||||
HEARTBEAT_SECONDS = 25
|
||||
# After a ``change``, the next one waits this long, collapsing the notices in between.
|
||||
COALESCE_SECONDS = 0.4
|
||||
_CHANGE = b"event: change\ndata: {}\n\n"
|
||||
_KEEPALIVE = b": keepalive\n\n"
|
||||
|
||||
|
||||
def _ready(version: dict) -> bytes:
|
||||
data = json.dumps(version, separators=(",", ":"), sort_keys=True)
|
||||
return f"event: ready\ndata: {data}\n\n".encode("utf-8")
|
||||
|
||||
|
||||
class _Stream:
|
||||
"""One open stream: a change flag set from any thread, and a close flag."""
|
||||
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self._loop = loop
|
||||
self.changed = asyncio.Event()
|
||||
self.closed = asyncio.Event()
|
||||
self.ready = False
|
||||
|
||||
def wake(self) -> None:
|
||||
"""Thread-safe: mark a change (the loop may already be gone)."""
|
||||
try:
|
||||
self._loop.call_soon_threadsafe(self.changed.set)
|
||||
except RuntimeError: # the loop closed
|
||||
pass
|
||||
|
||||
async def next(self, timeout: float) -> str:
|
||||
"""``change``, ``closed`` or ``idle`` (nothing within ``timeout``)."""
|
||||
waits = {
|
||||
asyncio.ensure_future(self.changed.wait()): "change",
|
||||
asyncio.ensure_future(self.closed.wait()): "closed",
|
||||
}
|
||||
try:
|
||||
done, _ = await asyncio.wait(
|
||||
waits, timeout=timeout, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
finally:
|
||||
for waiter in waits:
|
||||
waiter.cancel()
|
||||
if self.closed.is_set():
|
||||
return "closed"
|
||||
if done:
|
||||
self.changed.clear()
|
||||
return "change"
|
||||
return "idle"
|
||||
|
||||
async def closed_within(self, timeout: float) -> bool:
|
||||
"""Wait up to ``timeout``; true when the stream closed meanwhile."""
|
||||
try:
|
||||
await asyncio.wait_for(self.closed.wait(), timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class ReviewEventsMixin:
|
||||
"""The Needs review change stream."""
|
||||
|
||||
def _register_review_event_routes(self, routes: web.RouteTableDef) -> None:
|
||||
routes.get(EVENTS_URL)(self.review_events)
|
||||
|
||||
def _review_streams(self) -> set:
|
||||
streams = getattr(self, "_open_review_streams", None)
|
||||
if streams is None:
|
||||
streams = self._open_review_streams = set()
|
||||
return streams
|
||||
|
||||
def review_stream_count(self) -> int:
|
||||
"""How many change streams have sent ``ready`` and are open (tests and
|
||||
diagnostics): a queue change after this count moved is a ``change``
|
||||
event on them, never part of the version ``ready`` named."""
|
||||
return sum(1 for stream in self._review_streams() if stream.ready)
|
||||
|
||||
async def close_review_events(self, app) -> None:
|
||||
"""End every open stream; later requests get 503 (shutdown hook)."""
|
||||
self._review_events_closed = True
|
||||
for stream in list(self._review_streams()):
|
||||
stream.closed.set()
|
||||
|
||||
async def _queue_version(self) -> dict:
|
||||
"""The queue version for ``ready``, read after subscribing (so a change
|
||||
after this read is an event); ``{}`` when it cannot be read, and the
|
||||
page then re-reads."""
|
||||
try:
|
||||
return await self._run_in_executor(self.db.review_queue_version)
|
||||
except Exception:
|
||||
self.logger.warning("Needs review version read failed", exc_info=True)
|
||||
return {}
|
||||
|
||||
async def review_events(self, request: web.Request) -> web.StreamResponse:
|
||||
if getattr(self, "_review_events_closed", False):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "The server is shutting down."},
|
||||
status=503,
|
||||
)
|
||||
stream = _Stream(asyncio.get_running_loop())
|
||||
db_path = self.db.model.db_path
|
||||
unsubscribers = (
|
||||
subscribe_review_changes(db_path, stream.wake),
|
||||
subscribe_history_restore(db_path, stream.wake),
|
||||
)
|
||||
streams = self._review_streams()
|
||||
streams.add(stream)
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-store",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
try:
|
||||
version = await self._queue_version()
|
||||
await response.prepare(request)
|
||||
await response.write(_ready(version))
|
||||
stream.ready = True
|
||||
while True:
|
||||
outcome = await stream.next(HEARTBEAT_SECONDS)
|
||||
if outcome == "closed":
|
||||
break
|
||||
if outcome == "idle":
|
||||
await response.write(_KEEPALIVE)
|
||||
continue
|
||||
await response.write(_CHANGE)
|
||||
if await stream.closed_within(COALESCE_SECONDS):
|
||||
break
|
||||
await response.write_eof()
|
||||
except (ConnectionError, RuntimeError) as exc: # the tab went away
|
||||
self.logger.debug(f"Needs review stream closed ({exc})")
|
||||
finally:
|
||||
streams.discard(stream)
|
||||
for unsubscribe in unsubscribers:
|
||||
unsubscribe()
|
||||
return response
|
||||
@@ -127,6 +127,8 @@ export async function openProduction(page,route,theme,motion='no-preference') {
|
||||
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
|
||||
// SAVED VIEWS' Favorites count (D51), the concept's starred images in the concept fixture.
|
||||
await expect(page.locator('#saved-nav a[data-route="favorites"] .nav-count')).toHaveCount(1);
|
||||
// SAVED VIEWS' Needs review count (ruling R5): the exact pending count.
|
||||
await expect(page.locator('#saved-nav a[data-route="review"] .nav-count')).toHaveCount(1);
|
||||
}
|
||||
|
||||
/** Computed outline of the focused element, after keyboard focus so `:focus-visible` applies. */
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Needs review against the approved concept (#249 PR F, plan Task F3, Mock inventory M4: app.js renderReview
|
||||
// a749–760, style.css s407–415, s796–814 and their breakpoints, screenshot needs-review-tagging.png). Production runs
|
||||
// on the review fixture's concept-shaped queue (workspace_review_fixture.py `concept: true`: the concept's first three
|
||||
// image names and sizes, and the concept's first prompt on the open item), so every remaining difference is a recorded
|
||||
// deviation applied by mapConceptReview (D3, D29, D119) or a test failure. Paired captures of every state are attached
|
||||
// as artifacts; no screenshot baseline is added.
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { openConcept, openProduction, inventoryOf, mutate, saveImageTags, diffInventory, SIZES, THEMES } from './concept-inventory.mjs';
|
||||
import { reviewInventory, mapConceptReview, taggingInventory, mapConceptTagging, diffTaggingInventory } from './tagging-canonical-comparison.mjs';
|
||||
|
||||
const CONCEPT_FILE = 'alpine_00001.png';
|
||||
const CONCEPT_TAGS = ['cinematic', 'blue_hour', 'architecture'];
|
||||
const SURFACE = ['h2', 'p.small', 'div.row-actions.spaced-15', 'div.field', 'section.positive.prompt-section',
|
||||
'section.negative.prompt-section', 'div.dialog-actions'];
|
||||
// The fake model's server default instructions (tests/browser/server.py TestModel.default_prompt), D84.
|
||||
const INSTRUCTIONS = 'Describe the image';
|
||||
|
||||
const panel = page => page.locator('#workspace-main section.review-panel');
|
||||
const tagDialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
|
||||
async function seed(request, data = {}) {
|
||||
await expect(await request.post('/__test__/review/reset', { data: { items: 3, concept: true, ...data } })).toBeOK();
|
||||
}
|
||||
/** Production's Needs review page with its first item's sampler paths loaded. */
|
||||
async function openReview(page, theme) {
|
||||
await openProduction(page, 'review', theme);
|
||||
await expect(panel(page).getByRole('combobox', { name: 'Sampler context', exact: true })).toBeEnabled();
|
||||
await settle(page);
|
||||
}
|
||||
async function settle(page) {
|
||||
await page.mouse.move(0, 0);
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await Promise.all([...document.images].map(image => image.decode().catch(() => {})));
|
||||
});
|
||||
}
|
||||
const conceptInventory = page => page.evaluate(reviewInventory, '#main');
|
||||
const productionInventory = page => page.evaluate(reviewInventory, '#workspace-main');
|
||||
const node = (tree, prefix) => tree.tag?.startsWith(prefix) ? tree : tree.children?.map(child => node(child, prefix)).find(Boolean) ?? null;
|
||||
/** Computed values of CSS tokens, read through a probe element in the open page. */
|
||||
const tokens = (page, declarations) => page.evaluate(text => {
|
||||
const probe = document.createElement('i');
|
||||
probe.style.cssText = text;
|
||||
document.body.append(probe);
|
||||
const style = getComputedStyle(probe), values = [style.borderTopColor, style.backgroundColor];
|
||||
probe.remove();
|
||||
return values;
|
||||
}, declarations);
|
||||
|
||||
async function compareAt(page, request, width, height, theme) {
|
||||
await seed(request);
|
||||
await page.setViewportSize({ width, height });
|
||||
await openConcept(page, 'review', theme);
|
||||
await settle(page);
|
||||
const concept = await conceptInventory(page);
|
||||
await openReview(page, theme);
|
||||
const actual = await productionInventory(page);
|
||||
return { concept, actual, reference: mapConceptReview(concept, actual) };
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.errors = [];
|
||||
page.on('pageerror', error => page.errors.push(error.message));
|
||||
});
|
||||
test.afterEach(async ({ page, request }) => {
|
||||
expect(page.errors).toEqual([]);
|
||||
await request.post('/__test__/jobs/restart');
|
||||
});
|
||||
|
||||
for (const [width, height] of SIZES) for (const theme of THEMES) {
|
||||
test(`concept and production Metadata review match at ${width}x${height} ${theme}`, async ({ page, request }) => {
|
||||
const { actual, reference } = await compareAt(page, request, width, height, theme);
|
||||
expect(diffInventory(reference, actual), `${width}x${height} ${theme}`).toEqual([]);
|
||||
// Pinned concept values (M4), so a mapping mistake on both sides cannot pass unseen.
|
||||
const main = actual.main;
|
||||
expect(main.children.map(child => child.tag)).toEqual(['div.heading', 'div.notice', 'div.gallery-layout']);
|
||||
const notice = node(main, 'div.notice');
|
||||
const [accent, soft] = await tokens(page, 'border-top:1px solid var(--ca-accent);background:var(--soft-blue)');
|
||||
expect([notice.borderTopColor, notice.borderLeftColor, notice.backgroundColor]).toEqual([accent, accent, soft]);
|
||||
expect([notice.borderTopWidth, notice.borderTopLeftRadius, notice.paddingTop, notice.paddingLeft, notice.marginBottom])
|
||||
.toEqual(['1px', '6px', '15px', '18px', '22px']);
|
||||
expect(notice.flexDirection).toBe(width <= 700 ? 'column' : 'row');
|
||||
expect(notice.children.map(child => child.children.map(item => [item.tag, item.text])))
|
||||
.toEqual([[['h2', '3 images need a decision'], ['p', 'Multiple sampler paths were found. The existing image links were preserved. Choose the correct context or leave them unresolved.']]]);
|
||||
const layout = node(main, 'div.gallery-layout');
|
||||
expect(layout.gridTemplateColumns).toBe(width <= 700 ? `${layout.width}px` : `${layout.width - 22 - 310}px 310px`);
|
||||
const surface = node(main, 'section.review-panel');
|
||||
expect(surface.children.map(child => child.tag)).toEqual(SURFACE);
|
||||
expect(surface.children[0].text).toBe('Resolve the association');
|
||||
expect(surface.children[1].text).toBe(CONCEPT_FILE);
|
||||
const actions = surface.children.at(-1);
|
||||
expect(actions.children.map(button => [button.text, button.primary])).toEqual([['Leave for later', false], ['Apply association', true]]);
|
||||
expect(node(surface, 'select').options.at(-1)).toEqual(['Leave unresolved', false]);
|
||||
expect(node(main, 'div.image-grid').children.map(card => node(card, 'h3').text))
|
||||
.toEqual(['alpine_00001.png', 'dunes_00001.png', 'forest_00001.png']);
|
||||
expect(actual.noPageScroll).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('the gallery layout follows the concept at every breakpoint', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
// 310px beside the queue, 275px and a 15px gap at ≤1250px, 280px at ≤950px, one column at ≤700px.
|
||||
for (const [width, columns, gap] of [[1600, '310px', '22px'], [1200, '275px', '15px'], [900, '280px', '15px'], [390, null, '15px']]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await openConcept(page, 'review', 'dark');
|
||||
const concept = node((await conceptInventory(page)).main, 'div.gallery-layout');
|
||||
await openReview(page, 'dark');
|
||||
const actual = node((await productionInventory(page)).main, 'div.gallery-layout');
|
||||
expect([actual.gridTemplateColumns, actual.columnGap], `${width}px`).toEqual([concept.gridTemplateColumns, concept.columnGap]);
|
||||
expect([actual.gridTemplateColumns.split(' ').at(-1), actual.columnGap], `${width}px`).toEqual([columns ?? `${actual.width}px`, gap]);
|
||||
expect(actual.gridTemplateColumns.split(' ').length, `${width}px`).toBe(width <= 700 ? 1 : 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('SAVED VIEWS rows match the concept with Needs review after Favorites', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await openConcept(page, 'review', 'dark');
|
||||
const concept = (await inventoryOf(page, '#main')).chrome.navs.find(nav => nav.id === 'saved-nav');
|
||||
await openReview(page, 'dark');
|
||||
const actual = (await inventoryOf(page, '#workspace-main')).chrome.navs.find(nav => nav.id === 'saved-nav');
|
||||
// Sample data: the concept stars three images; this queue's fixture stars none. Needs review counts the queue (3).
|
||||
const reference = { ...concept, rows: concept.rows.map(row => row.text === 'Favorites' ? { ...row, count: { ...row.count, text: '0' } } : row) };
|
||||
expect(diffInventory(reference, actual)).toEqual([]);
|
||||
expect(actual.rows.map(row => [row.text, row.count?.text, row.current])).toEqual([['Favorites', '0', null], ['Needs review', '3', 'page']]);
|
||||
// The count's auto start margin follows the row's own text; every other count token is shared.
|
||||
const { text: favoriteCount, marginInlineStart: favoriteMargin, ...favoriteStyle } = actual.rows[0].count;
|
||||
const { text: reviewCount, marginInlineStart: reviewMargin, ...reviewStyle } = actual.rows[1].count;
|
||||
expect(reviewStyle).toEqual(favoriteStyle);
|
||||
expect(actual.rows[1].icon.d).toBe('M4 3h16v18H4zM8 7h8M8 12h8M8 17h5');
|
||||
});
|
||||
|
||||
test('review Tag dialog matches the needs-review-tagging reference', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await saveImageTags(request, `review/${CONCEPT_FILE}`, CONCEPT_TAGS);
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await openConcept(page, 'review', 'dark');
|
||||
await page.locator('#main section.surface').getByRole('button', { name: 'Tag image', exact: true }).click();
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await settle(page);
|
||||
const opened = await page.locator('dialog#modal').evaluate(taggingInventory);
|
||||
await page.keyboard.press('Escape');
|
||||
await openReview(page, 'dark');
|
||||
await panel(page).getByRole('button', { name: 'Tag image', exact: true }).click();
|
||||
await expect(tagDialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /\/image-annotations\/capture\//);
|
||||
await expect(tagDialog(page).locator('#manual-image-tags')).toHaveValue(CONCEPT_TAGS.join(', '));
|
||||
await settle(page);
|
||||
const actual = await page.locator('dialog#modal').evaluate(taggingInventory);
|
||||
const numberField = opened.modelPanel.fields.find(field => field.control.id === 'image-general-threshold');
|
||||
const mapped = mapConceptTagging(opened, { instructions: INSTRUCTIONS, chip: opened.heading.size, numberField });
|
||||
// D119: an undecided queue item names no prompt, so the heading and the preview's alt are the file name.
|
||||
const reference = { ...mapped, heading: { ...mapped.heading, h3: { ...mapped.heading.h3, text: CONCEPT_FILE },
|
||||
preview: { ...mapped.heading.preview, alt: CONCEPT_FILE } } };
|
||||
expect(actual.scopeNote.text).toBe('Tags belong to this image. Resolve its prompt association separately in Needs review.');
|
||||
expect(diffTaggingInventory(reference, actual)).toEqual([]);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(tagDialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the comparison notices a dropped notice border and reordered actions', async ({ page, request }) => {
|
||||
const { actual, reference } = await compareAt(page, request, 1600, 1100, 'dark');
|
||||
expect(diffInventory(reference, actual)).toEqual([]);
|
||||
const undoBorder = await mutate(page, { css: '#workspace-main .notice { border: 0 !important; }' });
|
||||
const borderless = await productionInventory(page);
|
||||
expect(diffInventory(reference, borderless).map(item => item.path)).toEqual(expect.arrayContaining([
|
||||
'main.children.1.borderTopWidth', 'main.children.1.borderTopStyle']));
|
||||
await undoBorder();
|
||||
await panel(page).locator('.dialog-actions').evaluate(row => row.prepend(row.lastElementChild));
|
||||
const reordered = await productionInventory(page);
|
||||
expect(diffInventory(reference, reordered).map(item => item.path)).toEqual(expect.arrayContaining([
|
||||
'main.children.2.children.1.children.6.children.0.text', 'main.children.2.children.1.children.6.children.0.primary']));
|
||||
await panel(page).locator('.dialog-actions').evaluate(row => row.prepend(row.lastElementChild));
|
||||
expect(diffInventory(reference, await productionInventory(page))).toEqual([]);
|
||||
});
|
||||
|
||||
// Paired captures for the PR (no baselines): the populated queue beside the concept in both themes and widths, then
|
||||
// the states the concept lacks (conflict, limited item, unavailable file, empty queue) as production captures.
|
||||
test('paired Needs review captures', async ({ page, request }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const shot = async name => {
|
||||
await settle(page);
|
||||
const file = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file, fullPage: true });
|
||||
await testInfo.attach(name, { path: file, contentType: 'image/png' });
|
||||
};
|
||||
await seed(request);
|
||||
for (const [width, height] of SIZES) for (const theme of THEMES) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await openConcept(page, 'review', theme);
|
||||
await shot(`concept-review-${width}-${theme}`);
|
||||
await openReview(page, theme);
|
||||
await shot(`production-review-${width}-${theme}`);
|
||||
}
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await seed(request, { items: 0, conflict: true, limited: true, unavailable: true });
|
||||
await openReview(page, 'dark');
|
||||
await panel(page).getByRole('combobox', { name: 'Sampler context', exact: true }).selectOption({ index: 0 });
|
||||
const decided = page.waitForResponse(item => /\/decision$/.test(item.url()));
|
||||
await panel(page).getByRole('button', { name: 'Apply association', exact: true }).click();
|
||||
expect((await decided).status()).toBe(409);
|
||||
await expect(panel(page).locator('.warning-note')).toBeVisible();
|
||||
await shot('production-review-conflict');
|
||||
for (const [index, name] of [[1, 'limited'], [2, 'unavailable']]) {
|
||||
await page.locator('#workspace-main .review-queue article').nth(index).getByRole('button', { name: /^Inspect / }).click();
|
||||
await expect(panel(page).locator('.warning-note')).toBeVisible();
|
||||
await shot(`production-review-${name}`);
|
||||
}
|
||||
await seed(request, { items: 0 });
|
||||
await openProduction(page, 'review', 'dark');
|
||||
await expect(page.locator('#workspace-main .notice h2')).toHaveText('No images need a decision');
|
||||
await shot('production-review-empty');
|
||||
});
|
||||
@@ -40,6 +40,7 @@ from tests.browser.workspace_library_editor_fixture import (
|
||||
)
|
||||
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
|
||||
from tests.browser.workspace_tagging_fixture import register_tagging_commands
|
||||
from tests.browser.workspace_review_fixture import register_review_commands
|
||||
|
||||
CANONICAL_ROOT = Path(__file__).resolve().parents[2]
|
||||
CANONICAL_ASSETS = {
|
||||
@@ -197,6 +198,7 @@ def create_app(root: Path) -> web.Application:
|
||||
library_editor_commands = register_library_editor_commands(routes, api, root)
|
||||
register_vocabulary_commands(routes, api, root)
|
||||
register_tagging_commands(routes, api, root, model)
|
||||
register_review_commands(routes, api, root)
|
||||
reveal_stub = register_reveal_stub(routes, api, root)
|
||||
register_activity_commands(app, routes, api)
|
||||
register_system_commands(app, routes, api, root)
|
||||
@@ -598,6 +600,8 @@ def create_app(root: Path) -> web.Application:
|
||||
api.db.close_all()
|
||||
|
||||
seed()
|
||||
# Open Needs review change streams end first, as on the real host.
|
||||
app.on_shutdown.append(api.close_review_events)
|
||||
app.on_cleanup.append(cleanup)
|
||||
app.add_routes(routes)
|
||||
return app
|
||||
|
||||
@@ -68,12 +68,12 @@ function remainingFooter(footer,spans) {
|
||||
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
|
||||
|
||||
/**
|
||||
* The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: Favorites with its count (D51), then the
|
||||
* saved views. D50: no "Needs review" row until the tag-review slice (maintainer ruling 2026-10-21).
|
||||
* The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: Favorites with its count (D51), Needs
|
||||
* review with the exact pending count (ruling R5), then the saved views. The concept's "3" is sample data: the concept
|
||||
* fixture records no review items, so production's count reads "0".
|
||||
*/
|
||||
function savedNav(nav) {
|
||||
const rows=nav.rows.filter(row=>row.text!=='Needs review');
|
||||
return {...nav,rows:rows.map((row,index)=>index===rows.length-1?{...row,gapAfter:null}:row)};
|
||||
return {...nav,rows:nav.rows.map(row=>row.text==='Needs review'?{...row,count:{...row.count,text:'0'}}:row)};
|
||||
}
|
||||
|
||||
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
|
||||
@@ -243,8 +243,9 @@ for(const [width,height] of SIZES)test(`a saved view row matches the concept's $
|
||||
await openProduction(page,'images','dark');
|
||||
const actual=await save('Save','#workspace-main');
|
||||
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
|
||||
const referenceRows=reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows;
|
||||
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
|
||||
.toEqual([['Favorites',reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows[0].count],['Golden hour',null]]);
|
||||
.toEqual([['Favorites',referenceRows[0].count],['Needs review',{...referenceRows[1].count,text:'0'}],['Golden hour',null]]);
|
||||
});
|
||||
|
||||
test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{
|
||||
|
||||
@@ -169,10 +169,9 @@ function mapOverviewConcept(reference, overview) {
|
||||
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
|
||||
'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) };
|
||||
const rows = {
|
||||
// D93: the real pending count; the action is unavailable (dimmed, as the Save row's D92) until the review page.
|
||||
// D93: the real pending count; Review metadata opens Needs review (the concept's go-review).
|
||||
'3 ambiguous image links': row => ({ ...row,
|
||||
title: `${number(overview.needs_review.pending)} ambiguous image link${overview.needs_review.pending === 1 ? '' : 's'}`,
|
||||
action: { ...row.action, opacity: '0.55', cursor: 'not-allowed' } }),
|
||||
title: `${number(overview.needs_review.pending)} ambiguous image link${overview.needs_review.pending === 1 ? '' : 's'}` }),
|
||||
// D93: no backup has been recorded (verified backups arrive with S8).
|
||||
'Backup status': row => ({ ...row, description: 'No retained backup record.' }),
|
||||
// D93: there is no paging, so the shared settings are grid and density.
|
||||
@@ -231,11 +230,10 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
});
|
||||
}
|
||||
|
||||
// The recorded differences, by path: D93 the review row (its real count, dimmed action), the backup row, the
|
||||
// The recorded differences, by path: D93 the review row (its real count), the backup row, the
|
||||
// preferences row and the sources row, and metric 4; D96 the statistics row; D92 the chip, the status and the dimmed
|
||||
// Save; D122 the nav label. (The fixture's first three metrics are the concept's own counts.)
|
||||
const UNMAPPED_PATHS = [
|
||||
'columns.surfaces.0.rows.0.action.cursor', 'columns.surfaces.0.rows.0.action.opacity',
|
||||
'columns.surfaces.0.rows.0.title', 'columns.surfaces.0.rows.1.description',
|
||||
'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description',
|
||||
'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label',
|
||||
|
||||
@@ -140,3 +140,152 @@ export function mapConceptTagging(reference,{instructions,limit=10,chip,numberFi
|
||||
export function withoutChipGeometry(inventory) {
|
||||
return {...inventory,suggestions:{...inventory.suggestions,rows:inventory.suggestions.rows.map(({paddingTop,paddingLeft,alignItems,...row})=>row)}};
|
||||
}
|
||||
|
||||
/**
|
||||
* Needs review (plan Task F3, Mock inventory M4: app.js renderReview a749–760, style.css s407–415, s796–814 and their
|
||||
* breakpoints): the page's main element as a tree, runs in the page. Every shown box below `main` to `depth` levels is
|
||||
* recorded with its tag and classes, its box relative to its parent, its fonts, edges and layout, a leaf's text, a
|
||||
* button's label and icon, a select's options; a `display: contents` node is replaced by its children (D20). A flex
|
||||
* row of actions also records the gaps between its children and its right inset, so a row whose button copy differs
|
||||
* (D29) still compares by order, spacing and alignment.
|
||||
*/
|
||||
export function reviewInventory(selector) {
|
||||
const main=document.querySelector(selector);
|
||||
// Half pixels: the concept's 13.5px line heights add up exactly, so a box that loses one line moves by 13.5px.
|
||||
const px=value=>Math.round(value*2)/2;
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const rect=node=>node.getBoundingClientRect();
|
||||
const shown=node=>node.getClientRects().length>0&&getComputedStyle(node).visibility!=='hidden';
|
||||
const relative=(node,origin)=>{const a=rect(node),b=rect(origin);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};};
|
||||
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','textTransform','textAlign');
|
||||
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginTop','marginRight','marginBottom','marginLeft',
|
||||
'borderTopWidth','borderTopStyle','borderTopColor','borderRightWidth','borderRightStyle','borderRightColor','borderBottomWidth',
|
||||
'borderBottomStyle','borderBottomColor','borderLeftWidth','borderLeftStyle','borderLeftColor','borderTopLeftRadius','backgroundColor',
|
||||
'boxShadow','outlineStyle');
|
||||
const layout=node=>css(node,'display','position','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap',
|
||||
'gridTemplateColumns','overflowX','overflowY','objectFit','minHeight','minWidth');
|
||||
const tagOf=node=>`${node.localName}${[...node.classList].sort().map(name=>`.${name}`).join('')}`;
|
||||
const textOf=node=>node.textContent.replace(/\s+/g,' ').trim();
|
||||
const boxed=node=>[...node.children].flatMap(child=>getComputedStyle(child).display==='contents'?boxed(child):[child]);
|
||||
const LEAVES=new Set(['button','select','input','img','svg','textarea','progress']);
|
||||
function nodeOf(node,origin,depth) {
|
||||
const base={tag:tagOf(node),...relative(node,origin),...font(node),...edges(node),...layout(node)};
|
||||
const name=node.localName;
|
||||
if(name==='svg')return {tag:base.tag,width:base.width,height:base.height,d:node.querySelector('path')?.getAttribute('d')??null,
|
||||
...css(node,'strokeWidth','color','verticalAlign')};
|
||||
if(name==='button')return {...base,text:textOf(node),label:node.getAttribute('aria-label'),pressed:node.getAttribute('aria-pressed'),
|
||||
disabled:node.getAttribute('aria-disabled')==='true'||node.disabled,primary:node.classList.contains('ca-primary'),
|
||||
children:boxed(node).filter(shown).map(child=>nodeOf(child,node,0))};
|
||||
if(name==='select')return {...base,id:node.id,options:[...node.options].map(option=>[option.textContent,option.disabled])};
|
||||
if(name==='input')return {...base,id:node.id,type:node.type,label:node.getAttribute('aria-label')};
|
||||
if(name==='img')return {...base,alt:node.getAttribute('alt')};
|
||||
const children=boxed(node).filter(shown);
|
||||
const item={...base,id:node.id||null,role:node.getAttribute('role'),label:node.getAttribute('aria-label'),
|
||||
text:children.length?null:textOf(node),htmlFor:node.htmlFor||null};
|
||||
if(/(^|\s)(dialog-actions|row-actions|image-card-actions)(\s|$)/.test(node.className)&&children.length) {
|
||||
const boxes=children.map(rect);
|
||||
item.gaps=boxes.slice(1).map((box,index)=>px(box.left-boxes[index].right));
|
||||
item.rightInset=px(rect(node).right-boxes.at(-1).right);
|
||||
}
|
||||
if(depth>0&&!LEAVES.has(name))item.children=children.map(child=>nodeOf(child,node,depth-1));
|
||||
return item;
|
||||
}
|
||||
return {main:nodeOf(main,main,8),noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
|
||||
}
|
||||
|
||||
/**
|
||||
* `tree` with `edit(node)` applied to every node `match` picks (a tag prefix or a predicate), depth first, new objects
|
||||
* only.
|
||||
*/
|
||||
function editNodes(tree,match,edit) {
|
||||
const children=tree.children?.map(child=>editNodes(child,match,edit));
|
||||
const node=children?{...tree,children}:tree;
|
||||
const picked=typeof match==='function'?match(node):node.tag?.startsWith(match);
|
||||
return picked?edit(node):node;
|
||||
}
|
||||
const findNode=(tree,prefix)=>tree.tag?.startsWith(prefix)?tree:tree.children?.map(child=>findNode(child,prefix)).find(Boolean)??null;
|
||||
|
||||
/**
|
||||
* The concept's Metadata review inventory (reviewInventory) with the recorded deviations applied, the remaining sample
|
||||
* data taken from production (`actual`) only where the concept's own copy cannot say it:
|
||||
* - D29: no "Sample review queue" chip in the notice; "Resolve the association" and "Apply association" without the
|
||||
* prototype's "sample";
|
||||
* - D119: queue cards carry no selection checkbox and no star (the overlay row stays, empty); a card's title is its
|
||||
* file name (an undecided item names no prompt) and its line is the extension only (D11 omits the unknown size and
|
||||
* seed, which a queue item does not carry); the image's alt is the file name; the prompt blocks' Copy and Send names
|
||||
* carry no prompt title;
|
||||
* - D3: the grid keeps the Images grid's `position: relative` (its cards stay in its flow);
|
||||
* - production's hooks and names: the `review-queue` / `review-panel` classes, the queue's list roles and name, the
|
||||
* panel heading's id (the region's name) and the prompt sections' region names;
|
||||
* - sample data: the Sampler context's candidate options (the file's own evidence; the last option, Leave unresolved,
|
||||
* stays compared) and the widths that follow from copy the deviations above change (a card's text column, the
|
||||
* panel's action buttons; their gaps and right inset stay compared).
|
||||
* Where that copy changes the height of a box, the boxes after it move by exactly that much: the stacked notice (390)
|
||||
* loses the chip and its row gap; a card whose concept line wraps (390: "1536 × 1152 · PNG · Seed 42001") takes
|
||||
* production's one-line height, its row of the grid shrinks by the most any of its cards lost, and in one column the
|
||||
* panel below the grid moves up by the grid's loss. The actions keep production's centred place in the card's info.
|
||||
*/
|
||||
export function mapConceptReview(reference,actual) {
|
||||
const named=(node,id)=>({...node,id});
|
||||
const actualCards=findNode(actual.main,'div.image-grid')?.children??[];
|
||||
const actualActions=findNode(actual.main,'div.dialog-actions');
|
||||
let cardIndex=0;
|
||||
let main={...reference.main,id:actual.main.id};
|
||||
let noticeLoss=0;
|
||||
main=editNodes(main,'div.notice',notice=>{
|
||||
const chip=notice.children.find(child=>child.tag==='span.chip');
|
||||
if(chip&¬ice.flexDirection==='column')noticeLoss=chip.height+parseFloat(notice.rowGap);
|
||||
return {...notice,height:notice.height-noticeLoss,children:notice.children.filter(child=>child!==chip)};
|
||||
});
|
||||
const noticeIndex=main.children.findIndex(child=>child.tag==='div.notice');
|
||||
main={...main,height:main.height-noticeLoss,children:main.children.map((child,index)=>index>noticeIndex?{...child,top:child.top-noticeLoss}:child)};
|
||||
const losses=[];
|
||||
main=editNodes(main,'div.image-grid',grid=>({...grid,tag:'div.image-grid.review-queue',position:'relative',role:'list',label:'Review queue'}));
|
||||
main=editNodes(main,'article.media-card',card=>{
|
||||
const twin=actualCards[cardIndex++];
|
||||
const filename=findNode(card,'button.image-open').label.replace(/^Open /,'');
|
||||
const art=editNodes(card.children[0],'div.image-overlays',overlays=>({...overlays,height:0,text:'',children:[]}));
|
||||
const withAlt=editNodes(art,'img',img=>({...img,alt:filename}));
|
||||
const info=card.children[1];
|
||||
const [copy,...rest]=info.children;
|
||||
const twinCopy=twin?.children[1]?.children[0];
|
||||
const [h3,p]=copy.children;
|
||||
const twinLine=twinCopy?.children[1];
|
||||
const loss=twinLine?p.height-twinLine.height:0;
|
||||
losses.push({top:card.top,loss});
|
||||
const mappedCopy={...copy,width:twinCopy?.width,height:copy.height-loss,children:[{...h3,text:filename,width:twinCopy?.children[0]?.width},
|
||||
{...p,text:'PNG',width:twinLine?.width,height:p.height-loss}]};
|
||||
const [actions,...after]=rest;
|
||||
const twinActions=twin?.children[1]?.children[1];
|
||||
const mappedActions=actions&&loss?{...actions,top:twinActions?.top}:actions;
|
||||
return {...card,role:'listitem',height:card.height-loss,
|
||||
children:[withAlt,{...info,height:info.height-loss,children:[mappedCopy,...(actions?[mappedActions]:[]),...after]},...card.children.slice(2)]};
|
||||
});
|
||||
// Each grid row loses what its most shortened card lost; later rows move up by the rows before them.
|
||||
const rowLoss=new Map();
|
||||
for(const {top,loss} of losses)rowLoss.set(top,Math.max(rowLoss.get(top)??0,loss));
|
||||
const lossBefore=top=>[...rowLoss].filter(([rowTop])=>rowTop<top).reduce((sum,[,loss])=>sum+loss,0);
|
||||
const gridLoss=[...rowLoss.values()].reduce((sum,loss)=>sum+loss,0);
|
||||
main=editNodes(main,'div.image-grid',grid=>({...grid,height:grid.height-gridLoss,
|
||||
children:grid.children.map(card=>({...card,top:card.top-lossBefore(card.top)}))}));
|
||||
main=editNodes(main,'div.gallery-layout',layout=>{
|
||||
if(!gridLoss||layout.gridTemplateColumns.trim().split(/\s+/).length>1)return layout;
|
||||
return {...layout,height:layout.height-gridLoss,children:layout.children.map((child,index)=>index?{...child,top:child.top-gridLoss}:child)};
|
||||
});
|
||||
if(gridLoss&&findNode(main,'div.gallery-layout').gridTemplateColumns.trim().split(/\s+/).length===1)main={...main,height:main.height-gridLoss};
|
||||
main=editNodes(main,'section.surface',surface=>{
|
||||
const [h2,...rest]=surface.children;
|
||||
return {...surface,tag:'section.review-panel.surface',children:[named({...h2,text:h2.text.replace('Resolve the sample association','Resolve the association')},'review-panel-title'),...rest]};
|
||||
});
|
||||
main=editNodes(main,'select',select=>{
|
||||
const twin=findNode(actual.main,'select');
|
||||
return select.id==='review-context'?{...select,options:[...twin.options.slice(0,-1),select.options.at(-1)]}:select;
|
||||
});
|
||||
main=editNodes(main,node=>/^section\..*\bprompt-section\b/.test(node.tag),section=>({...section,label:section.tag.includes('positive')?'Positive prompt':'Negative prompt'}));
|
||||
main=editNodes(main,node=>node.tag?.startsWith('button')&&/^(Copy|Send) (positive|negative) prompt/.test(node.label??''),
|
||||
button=>({...button,label:button.label.replace(/: .+$/,'')}));
|
||||
main=editNodes(main,'div.dialog-actions',actions=>({...actions,children:actions.children.map((button,index)=>({...button,
|
||||
text:button.text.replace('Apply sample association','Apply association'),
|
||||
left:actualActions?.children[index]?.left,width:actualActions?.children[index]?.width}))}));
|
||||
return {...reference,main};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
// Needs review (#249 PR F, plan Tasks F1 and F2, Mock inventory M4): the SAVED VIEWS row and its count, the Metadata
|
||||
// review page over the real review routes, and Activity's Review metadata. Items are recorded by the real recorder
|
||||
// (tests/browser/workspace_review_fixture.py) through the monitor's entry point; the conflict item's file was linked
|
||||
// to another prompt after it was queued. An open page re-reads its queue from the review change stream (F2).
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
const review = '/prompt_manager/workspace#/review';
|
||||
const main = page => page.locator('#workspace-main');
|
||||
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
|
||||
const reviewRow = page => savedNav(page).locator('a[data-route="review"]');
|
||||
const panel = page => main(page).getByRole('region', { name: 'Resolve the association', exact: true });
|
||||
const context = page => panel(page).getByRole('combobox', { name: 'Sampler context', exact: true });
|
||||
const cards = page => main(page).locator('.review-queue article.media-card');
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const NOTICE = 'Multiple sampler paths were found. The existing image links were preserved. Choose the correct context or leave them unresolved.';
|
||||
const EMPTY = 'Ambiguous images are added when a metadata scan or a new generation finds more than one sampler path.';
|
||||
const OPTIONS = ['Sampler 11 · Golden dunes at dusk / Soft negative, blur', 'Sampler 13 · Harbor lights at night / Soft negative, blur',
|
||||
'Leave unresolved'];
|
||||
|
||||
async function seed(request, items = 3, special = {}) {
|
||||
const response = await request.post('/__test__/review/reset', { data: { items, ...special } });
|
||||
await expect(response).toBeOK();
|
||||
return response.json();
|
||||
}
|
||||
async function pending(request) {
|
||||
return (await (await request.get('/prompt_manager/review/items?limit=1')).json()).total;
|
||||
}
|
||||
const links = async request => (await (await request.get('/__test__/review/links')).json()).links;
|
||||
const streams = async request => (await (await request.get('/__test__/review/streams')).json());
|
||||
const apply = page => panel(page).getByRole('button', { name: 'Apply association', exact: true });
|
||||
const warning = page => panel(page).locator('.warning-note');
|
||||
/** The page with its first item's sampler paths loaded. */
|
||||
async function open(page) {
|
||||
await page.goto(review);
|
||||
await expect(context(page)).toBeEnabled();
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
page.reviewErrors = [];
|
||||
page.on('pageerror', error => page.reviewErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async ({ page, browserName, request }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.reviewErrors).toEqual([]);
|
||||
await request.post('/__test__/jobs/restart');
|
||||
});
|
||||
|
||||
test('queue shows persisted items and the nav count', async ({ page, request }) => {
|
||||
await seed(request, 3);
|
||||
await open(page);
|
||||
// SAVED VIEWS: Favorites, then Needs review with the exact pending count (ruling R5).
|
||||
await expect(savedNav(page).locator('a').nth(1)).toHaveAttribute('data-route', 'review');
|
||||
await expect(reviewRow(page)).toHaveText('Needs review3');
|
||||
await expect(reviewRow(page)).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page).toHaveTitle('Review · Prompt Manager');
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Review');
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Metadata review');
|
||||
await expect(main(page).locator('.heading p.muted')).toHaveText('Uncertain evidence stays visible until you decide.');
|
||||
await expect(main(page).locator('.notice h2')).toHaveText('3 images need a decision');
|
||||
await expect(main(page).locator('.notice p')).toHaveText(NOTICE);
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
await expect(cards(page).first()).toHaveClass(/selected/);
|
||||
await expect(panel(page).locator('p.small')).toHaveText('review_0000.png');
|
||||
await expect(context(page).locator('option')).toHaveText(OPTIONS);
|
||||
await expect(context(page)).toHaveValue(/^[0-9a-f]{64}$/);
|
||||
await expect(panel(page).locator('.prompt-section.positive .prompt-text')).toHaveText('Golden dunes at dusk');
|
||||
await expect(panel(page).locator('.prompt-section.negative .prompt-text')).toHaveText('Soft negative, blur');
|
||||
await context(page).selectOption({ label: OPTIONS[1] });
|
||||
await expect(panel(page).locator('.prompt-section.positive .prompt-text')).toHaveText('Harbor lights at night');
|
||||
await context(page).selectOption({ label: 'Leave unresolved' });
|
||||
await expect(panel(page).locator('.prompt-section.positive .prompt-text')).toHaveText('Multiple possible prompts. Source could not be determined.');
|
||||
// Info selects another item.
|
||||
await cards(page).nth(1).getByRole('button', { name: 'Inspect review_0001.png', exact: true }).click();
|
||||
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
|
||||
await expect(cards(page).nth(1)).toHaveClass(/selected/);
|
||||
await expect(context(page)).toBeEnabled();
|
||||
// The card's image opens the shared viewer.
|
||||
await cards(page).nth(1).getByRole('button', { name: 'Open review_0001.png', exact: true }).click();
|
||||
await expect(page.locator('#viewer-dialog')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
|
||||
// Open activity.
|
||||
await main(page).getByRole('button', { name: 'Open activity', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/activity$/);
|
||||
});
|
||||
|
||||
test('apply candidate links the pair and the item leaves the queue and the count drops', async ({ page, request }) => {
|
||||
await seed(request, 2);
|
||||
await open(page);
|
||||
await expect(reviewRow(page)).toHaveText('Needs review2');
|
||||
const decided = page.waitForResponse(item => /\/review\/items\/[^/]+\/decision$/.test(item.url()));
|
||||
await panel(page).getByRole('button', { name: 'Apply association', exact: true }).click();
|
||||
expect((await decided).status()).toBe(200);
|
||||
await expect(toast(page)).toHaveText('Association accepted.');
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(reviewRow(page)).toHaveText('Needs review1');
|
||||
expect(await pending(request)).toBe(1);
|
||||
// The first sampler path's pair is linked to that file, and only to it.
|
||||
expect(await links(request)).toEqual([{ filename: 'review_0000.png', prompt: 'Golden dunes at dusk',
|
||||
pair_id: expect.any(Number), positive: 'Golden dunes at dusk', negative: 'Soft negative, blur' }]);
|
||||
});
|
||||
|
||||
test('conflict leaves links unchanged and explains', async ({ page, request }) => {
|
||||
// Queued first, then linked to another prompt (before a scan or monitor pass could settle it).
|
||||
await seed(request, 0, { conflict: true });
|
||||
const before = await links(request);
|
||||
expect(before).toEqual([{ filename: 'conflict.png', prompt: 'Misty forest path', pair_id: null, positive: null,
|
||||
negative: null }]);
|
||||
await open(page);
|
||||
await expect(panel(page).locator('p.small')).toHaveText('conflict.png');
|
||||
await expect(context(page).locator('option')).toHaveText(OPTIONS);
|
||||
const decided = page.waitForResponse(item => /\/decision$/.test(item.url()));
|
||||
await apply(page).click();
|
||||
const answer = await decided;
|
||||
expect(answer.status()).toBe(409);
|
||||
expect((await answer.json()).code).toBe('association-conflict');
|
||||
await expect(warning(page)).toHaveText('This image is already linked to another prompt or negative, so this sampler path cannot be attached safely. The existing links were preserved.');
|
||||
await expect(page).toHaveURL(/#\/review$/);
|
||||
await expect(context(page)).toBeEnabled();
|
||||
await expect(context(page)).toHaveValue(/^[0-9a-f]{64}$/);
|
||||
expect(await links(request)).toEqual(before);
|
||||
expect(await pending(request)).toBe(1);
|
||||
await expect(reviewRow(page)).toHaveText('Needs review1');
|
||||
// The file can still be left unresolved, which links nothing either.
|
||||
await context(page).selectOption({ label: 'Leave unresolved' });
|
||||
await apply(page).click();
|
||||
await expect(toast(page)).toHaveText('Left unresolved.');
|
||||
expect(await links(request)).toEqual(before);
|
||||
expect(await pending(request)).toBe(0);
|
||||
});
|
||||
|
||||
test('a limited item and an unavailable file can only be left unresolved', async ({ page, request }) => {
|
||||
await seed(request, 0, { limited: true, unavailable: true });
|
||||
await open(page);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(panel(page).locator('p.small')).toHaveText('limited.png');
|
||||
await expect(context(page).locator('option')).toHaveText(['Leave unresolved']);
|
||||
await expect(warning(page)).toHaveText('This file has too many sampler paths to list. You can leave it unresolved.');
|
||||
// The unavailable file: no current root lists it, so its media and Tag are inert.
|
||||
const unavailable = cards(page).nth(1);
|
||||
await expect(unavailable.locator('.image-open')).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(unavailable.locator('.image-card-actions button').last()).toHaveAttribute('aria-disabled', 'true');
|
||||
// F3: its empty media button keeps the concept's 1.33 media area, so the card lines up with its neighbours.
|
||||
await expect(cards(page).first().locator('.image-open img')).toBeVisible();
|
||||
const sizes = await cards(page).evaluateAll(nodes => nodes.map(node => {
|
||||
const art = node.querySelector('.image-open').getBoundingClientRect();
|
||||
return [Math.round(art.width), Math.round(art.height), Math.round(node.getBoundingClientRect().height)];
|
||||
}));
|
||||
expect(sizes[1]).toEqual(sizes[0]);
|
||||
await unavailable.getByRole('button', { name: 'Inspect unavailable.png', exact: true }).click();
|
||||
await expect(panel(page).locator('p.small')).toHaveText('unavailable.png');
|
||||
await expect(warning(page)).toHaveText('This file is not under a current image source, so it cannot be opened. You can leave it unresolved.');
|
||||
await expect(panel(page).getByRole('button', { name: 'Tag image', exact: true })).toHaveAttribute('aria-disabled', 'true');
|
||||
const observes = [];
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/observe')) observes.push(item.url()); });
|
||||
await apply(page).click();
|
||||
await expect(toast(page)).toHaveText('Left unresolved.');
|
||||
expect(observes).toEqual([]);
|
||||
expect(await pending(request)).toBe(1);
|
||||
});
|
||||
|
||||
test('leave unresolved removes the item from pending', async ({ page, request }) => {
|
||||
await seed(request, 2);
|
||||
await open(page);
|
||||
await context(page).selectOption({ label: 'Leave unresolved' });
|
||||
await panel(page).getByRole('button', { name: 'Apply association', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Left unresolved.');
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
expect(await pending(request)).toBe(1);
|
||||
});
|
||||
|
||||
test('leave for later writes nothing and goes to Images', async ({ page, request }) => {
|
||||
await seed(request, 2);
|
||||
await open(page);
|
||||
const writes = [];
|
||||
page.on('request', item => { if (item.method() !== 'GET') writes.push(item.url()); });
|
||||
await panel(page).getByRole('button', { name: 'Leave for later', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
expect(writes).toEqual([]);
|
||||
expect(await pending(request)).toBe(2);
|
||||
});
|
||||
|
||||
test('tag image from review saves tags and the item remains pending', async ({ page, request }) => {
|
||||
await seed(request, 1);
|
||||
await open(page);
|
||||
const before = await links(request);
|
||||
await panel(page).getByRole('button', { name: 'Tag image', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator('p.tag-scope-note')).toHaveText('Tags belong to this image. Resolve its prompt association separately in Needs review.');
|
||||
await dialog.getByRole('textbox', { name: 'Image tags · edit manually', exact: true }).fill('dunes');
|
||||
await dialog.getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for review_0000.png. Prompt association still awaits review.');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(await pending(request)).toBe(1);
|
||||
await expect(page).toHaveURL(/#\/review$/);
|
||||
// The item is untouched: still listed, still open, nothing linked.
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(context(page)).toBeEnabled();
|
||||
expect(await links(request)).toEqual(before);
|
||||
const [item] = (await (await request.get('/prompt_manager/review/items')).json()).items;
|
||||
expect([item.state, item.revision]).toEqual(['pending', 0]);
|
||||
// The card's Tag opens the same dialog.
|
||||
await cards(page).first().getByRole('button', { name: 'Tag or auto-tag review_0000.png', exact: true }).click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator('p.tag-scope-note')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('an empty queue shows the empty notice only', async ({ page, request }) => {
|
||||
await seed(request, 0);
|
||||
await page.goto(review);
|
||||
await expect(main(page).locator('.notice h2')).toHaveText('No images need a decision');
|
||||
await expect(main(page).locator('.notice p')).toHaveText(EMPTY);
|
||||
await expect(main(page).locator('.gallery-layout')).toHaveCount(0);
|
||||
await expect(reviewRow(page)).toHaveText('Needs review0');
|
||||
});
|
||||
|
||||
test('load more appends the next page', async ({ page, request }) => {
|
||||
await seed(request, 45);
|
||||
await open(page);
|
||||
await expect(cards(page)).toHaveCount(40);
|
||||
const continuation = main(page).locator('.infinite-continuation');
|
||||
await expect(continuation.locator('p')).toHaveText('40 of 45 images');
|
||||
await continuation.getByRole('button', { name: 'Load more images', exact: true }).click();
|
||||
await expect(cards(page)).toHaveCount(45);
|
||||
await expect(continuation).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a scan job with review items offers Review metadata in Activity', async ({ page, request }) => {
|
||||
await seed(request, 1);
|
||||
const started = await (await request.post('/__test__/jobs/start', { data: { total: 10, review_items: 2 } })).json();
|
||||
await expect(await request.post(`/__test__/jobs/${started.job.id}/finish`, { data: {} })).toBeOK();
|
||||
const plain = await (await request.post('/__test__/jobs/start', { data: { total: 10 } })).json();
|
||||
await expect(await request.post(`/__test__/jobs/${plain.job.id}/finish`, { data: {} })).toBeOK();
|
||||
await page.goto('/prompt_manager/workspace#/activity');
|
||||
const item = main(page).locator(`.activity-item[data-job-id="${started.job.id}"]`);
|
||||
await expect(item.getByRole('button', { name: 'Review metadata', exact: true })).toBeVisible();
|
||||
await expect(main(page).locator(`.activity-item[data-job-id="${plain.job.id}"] button`)).toHaveCount(0);
|
||||
await page.goto(`/prompt_manager/workspace#/activity/${started.job.id}`);
|
||||
const actions = main(page).locator('.heading-actions button');
|
||||
await expect(actions).toHaveText(['Download report', 'Review metadata']);
|
||||
await actions.nth(1).click();
|
||||
await expect(page).toHaveURL(/#\/review$/);
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Metadata review');
|
||||
});
|
||||
|
||||
test('a failed item read says so and Retry reads it again', async ({ page, request }) => {
|
||||
await seed(request, 1);
|
||||
await page.route(url => /\/prompt_manager\/review\/items\/[0-9a-f-]{36}$/.test(url.pathname),
|
||||
route => route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ success: false, code: 'review-failed', error: 'x' }) }),
|
||||
{ times: 1 });
|
||||
await page.goto(review);
|
||||
await expect(panel(page).locator('.warning-note')).toHaveText('This item could not be read. Select it again to retry.');
|
||||
await expect(panel(page).getByText('Could not read prompts from this file.', { exact: true })).toBeVisible();
|
||||
await expect(panel(page).getByRole('button', { name: 'Apply association', exact: true })).toHaveAttribute('aria-disabled', 'true');
|
||||
await panel(page).getByRole('button', { name: 'Retry prompt details', exact: true }).click();
|
||||
await expect(context(page)).toBeEnabled();
|
||||
await expect(context(page).locator('option')).toHaveText(OPTIONS);
|
||||
await expect(panel(page).locator('.warning-note')).toBeHidden();
|
||||
});
|
||||
|
||||
test('a failed queue read offers Retry', async ({ page, request }) => {
|
||||
await seed(request, 1);
|
||||
await page.route(url => url.pathname.endsWith('/prompt_manager/review/items') && url.searchParams.get('limit') === '40',
|
||||
route => route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ success: false, code: 'review-failed', error: 'x' }) }),
|
||||
{ times: 1 });
|
||||
await page.goto(review);
|
||||
await expect(main(page).getByRole('alert')).toHaveText('The review queue could not be read.');
|
||||
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(context(page)).toBeEnabled();
|
||||
await expect(main(page).locator('.notice h2')).toHaveText('1 image needs a decision');
|
||||
});
|
||||
|
||||
test('keyboard focus labels and touch targets', async ({ page, request }) => {
|
||||
await seed(request, 2);
|
||||
await open(page);
|
||||
const first = cards(page).first();
|
||||
for (const [name, role] of [['Open review_0000.png', 'button'], ['Inspect review_0000.png', 'button'],
|
||||
['Tag or auto-tag review_0000.png', 'button']]) await expect(first.getByRole(role, { name, exact: true })).toBeVisible();
|
||||
await expect(main(page).getByRole('list', { name: 'Review queue', exact: true })).toBeVisible();
|
||||
await expect(panel(page)).toBeVisible();
|
||||
// Keyboard: Info on the second card opens it, and the panel's controls take focus in their order.
|
||||
const info = cards(page).nth(1).getByRole('button', { name: 'Inspect review_0001.png', exact: true });
|
||||
await info.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
|
||||
await expect(context(page)).toBeEnabled();
|
||||
await context(page).focus();
|
||||
await expect(context(page)).toBeFocused();
|
||||
const order = await panel(page).evaluate(node => [...node.querySelectorAll('button, select')]
|
||||
.filter(control => control.offsetParent !== null).map(control => control.getAttribute('aria-label') || control.textContent.trim()));
|
||||
expect(order[0]).toBe('Tag image');
|
||||
expect(order.slice(-2)).toEqual(['Leave for later', 'Apply association']);
|
||||
// Targets: the panel's controls are 40px on a coarse pointer (the concept's coarse buttons) and 24px otherwise (the
|
||||
// WCAG 2.2 minimum); the card's buttons keep the concept's compact size, never under 24px (its ≤700px rule).
|
||||
const coarse = await page.evaluate(() => matchMedia('(pointer: coarse)').matches);
|
||||
const sized = [
|
||||
[panel(page).getByRole('button', { name: 'Tag image', exact: true }), coarse ? 40 : 24],
|
||||
[context(page), coarse ? 40 : 24],
|
||||
[panel(page).getByRole('button', { name: 'Leave for later', exact: true }), coarse ? 40 : 24],
|
||||
[apply(page), coarse ? 40 : 24],
|
||||
[first.getByRole('button', { name: 'Inspect review_0000.png', exact: true }), 24],
|
||||
[first.getByRole('button', { name: 'Tag or auto-tag review_0000.png', exact: true }), 24],
|
||||
];
|
||||
for (const [control, minimum] of sized) {
|
||||
const box = await control.boundingBox();
|
||||
const label = await control.evaluate(node => node.getAttribute('aria-label') || node.textContent.trim().slice(0, 40));
|
||||
expect(box.height, label).toBeGreaterThanOrEqual(minimum);
|
||||
expect(box.width, label).toBeGreaterThanOrEqual(24);
|
||||
}
|
||||
// Enter on Leave for later returns to Images.
|
||||
await panel(page).getByRole('button', { name: 'Leave for later', exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
});
|
||||
|
||||
test('an open page shows items the monitor adds and keeps the open choice', async ({ page, request }) => {
|
||||
await seed(request, 1);
|
||||
await open(page);
|
||||
await expect.poll(async () => (await streams(request)).streams).toBe(1);
|
||||
await context(page).selectOption({ label: OPTIONS[1] });
|
||||
await expect(reviewRow(page)).toHaveText('Needs review1');
|
||||
await expect(await request.post('/__test__/review/add', { data: { kind: 'monitor' } })).toBeOK();
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(main(page).locator('.notice h2')).toHaveText('2 images need a decision');
|
||||
await expect(reviewRow(page)).toHaveText('Needs review2');
|
||||
await expect(cards(page).first()).toHaveClass(/selected/);
|
||||
await expect(panel(page).locator('p.small')).toHaveText('review_0000.png');
|
||||
await expect(context(page).locator('option:checked')).toHaveText(OPTIONS[1]);
|
||||
await expect(warning(page)).toBeHidden();
|
||||
});
|
||||
|
||||
test('an open page shows items a metadata scan adds', async ({ page, request }) => {
|
||||
await seed(request, 1);
|
||||
await expect(await request.post('/__test__/review/add', { data: { kind: 'file' } })).toBeOK();
|
||||
await open(page);
|
||||
await expect.poll(async () => (await streams(request)).streams).toBe(1);
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
const started = await request.post('/prompt_manager/jobs/scan', { data: { scope: 'new' } });
|
||||
expect(started.status()).toBe(202);
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(cards(page).nth(1).getByRole('button', { name: 'Inspect added_0000.png', exact: true })).toBeVisible();
|
||||
await expect(reviewRow(page)).toHaveText('Needs review2');
|
||||
});
|
||||
|
||||
test('a decision in another tab leaves the open page and opens the next image', async ({ page, request, context: browserContext }) => {
|
||||
await seed(request, 2);
|
||||
await open(page);
|
||||
await expect.poll(async () => (await streams(request)).streams).toBe(1);
|
||||
const other = await browserContext.newPage();
|
||||
await open(other);
|
||||
await context(other).selectOption({ label: 'Leave unresolved' });
|
||||
await apply(other).click();
|
||||
await expect(other).toHaveURL(/#\/images$/);
|
||||
await other.close();
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(panel(page).locator('p.small')).toHaveText('review_0001.png');
|
||||
await expect(warning(page)).toHaveText('review_0000.png was decided elsewhere, so the next image is open.');
|
||||
await expect(main(page).locator('.notice h2')).toHaveText('1 image needs a decision');
|
||||
await expect(reviewRow(page)).toHaveText('Needs review1');
|
||||
await expect(context(page)).toBeEnabled();
|
||||
});
|
||||
|
||||
/** The queue reads (`?limit=40`, cursors) and count reads (`?limit=1`) the page sends from now on. */
|
||||
function reviewReads(page) {
|
||||
const reads = [];
|
||||
page.on('request', item => {
|
||||
const url = new URL(item.url());
|
||||
if (url.pathname === '/prompt_manager/review/items') reads.push(url.searchParams.get('cursor') ? 'more' : url.search);
|
||||
});
|
||||
return reads;
|
||||
}
|
||||
|
||||
test('a visit reads its queue once, and a change reads it once more', async ({ page, request }) => {
|
||||
// F3 ruling: the stream's ready must not read a queue the first read already holds.
|
||||
await seed(request, 1);
|
||||
const reads = reviewReads(page);
|
||||
await open(page);
|
||||
await expect.poll(async () => (await streams(request)).streams).toBe(1);
|
||||
await expect(await request.post('/__test__/review/add', { data: { kind: 'monitor' } })).toBeOK();
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(reviewRow(page)).toHaveText('Needs review2');
|
||||
// The page's first read and the change's re-read; the shell's count read at start, and the row follows the page.
|
||||
expect(reads.filter(search => search === '?limit=40')).toHaveLength(2);
|
||||
expect(reads.filter(search => search === '?limit=1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a ready whose version cannot be read re-reads the queue once', async ({ page, request }) => {
|
||||
// The stream's ready names no readable version (an older server, a proxy that rewrote it): the page reads again.
|
||||
await seed(request, 1);
|
||||
const reads = reviewReads(page);
|
||||
// M1: `retry` keeps the browser from reconnecting the ended stream during the test, so exactly one re-read follows.
|
||||
await page.route(url => url.pathname === '/prompt_manager/review/events', route => route.fulfill({
|
||||
status: 200, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store' },
|
||||
body: 'retry: 3600000\nevent: ready\ndata: not json\n\n' }));
|
||||
let reread = 0;
|
||||
const second = page.waitForResponse(item => item.url().endsWith('/prompt_manager/review/items?limit=40') && ++reread === 2);
|
||||
await open(page);
|
||||
await second;
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
expect(reads.filter(search => search === '?limit=40')).toHaveLength(2);
|
||||
});
|
||||
|
||||
const live = page => main(page).locator('.heading .review-live');
|
||||
const NOT_LIVE = 'Live updates are paused: changes made elsewhere appear once the connection returns.';
|
||||
|
||||
test('a stream the browser gives up on is opened again, and the page says it is not live meanwhile', async ({ page, request }) => {
|
||||
// I3: a 503 (shutdown, or a proxy while the backend restarts) fails an EventSource for good.
|
||||
await seed(request, 1);
|
||||
let attempts = 0;
|
||||
await page.route(url => url.pathname === '/prompt_manager/review/events', route => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return route.fulfill({ status: 503, contentType: 'application/json', body: '{"success":false}' });
|
||||
return route.fallback();
|
||||
});
|
||||
await open(page);
|
||||
await expect(live(page)).toHaveText(NOT_LIVE);
|
||||
await expect(live(page)).toBeVisible();
|
||||
// The page opens the stream again (after 3 s) and is live once it sends ready.
|
||||
await expect.poll(async () => (await streams(request)).streams, { timeout: 15000 }).toBe(1);
|
||||
await expect(live(page)).toBeHidden();
|
||||
await expect(await request.post('/__test__/review/add', { data: { kind: 'monitor' } })).toBeOK();
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
|
||||
/** Make the page hidden or visible as a tab switch does. */
|
||||
async function setVisible(page, visible) {
|
||||
await page.evaluate(shown => {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => !shown });
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => (shown ? 'visible' : 'hidden') });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}, visible);
|
||||
}
|
||||
|
||||
test('a hidden tab closes its stream and a visible one reads only what changed meanwhile', async ({ page, request }) => {
|
||||
// I4: background tabs must not hold one of the origin's six connections.
|
||||
await seed(request, 1);
|
||||
const reads = reviewReads(page);
|
||||
await open(page);
|
||||
await expect.poll(async () => (await streams(request)).streams).toBe(1);
|
||||
await setVisible(page, false);
|
||||
// The server drops a closed stream at its next write: this monitor addition is one.
|
||||
await expect(await request.post('/__test__/review/add', { data: { kind: 'monitor' } })).toBeOK();
|
||||
await expect.poll(async () => (await streams(request)).streams).toBe(0);
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
expect(reads.filter(search => search === '?limit=40')).toHaveLength(1);
|
||||
// Visible again: the reopened stream's ready names a newer version, so the page reads once.
|
||||
const reread = page.waitForResponse(item => item.url().endsWith('/prompt_manager/review/items?limit=40'));
|
||||
await setVisible(page, true);
|
||||
await expect.poll(async () => (await streams(request)).streams).toBe(1);
|
||||
await reread;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(live(page)).toBeHidden();
|
||||
expect(reads.filter(search => search === '?limit=40')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a re-read refreshes a card whose media or target changed', async ({ page, request }) => {
|
||||
// M6 and M10: a root came or went between reads; the card follows, and a known thumbnail is its media.
|
||||
await seed(request, 2);
|
||||
let rewrite = false;
|
||||
await page.route(url => url.pathname === '/prompt_manager/review/items' && url.search === '?limit=40', async route => {
|
||||
if (!rewrite) return route.fallback();
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
body.items[0] = { ...body.items[0], thumbnail_url: `${body.items[0].url}&thumb=1` };
|
||||
body.items[1] = { ...body.items[1], url: null, thumbnail_url: null, target: null, unavailable: true };
|
||||
return route.fulfill({ response, json: body });
|
||||
});
|
||||
await open(page);
|
||||
await expect.poll(async () => (await streams(request)).streams).toBe(1);
|
||||
const second = cards(page).nth(1);
|
||||
await expect(second.getByRole('button', { name: 'Open review_0001.png', exact: true })).not.toHaveAttribute('aria-disabled', 'true');
|
||||
rewrite = true;
|
||||
await expect(await request.post('/__test__/review/add', { data: { kind: 'monitor' } })).toBeOK();
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
await expect(cards(page).first().locator('img')).toHaveAttribute('src', /&thumb=1$/);
|
||||
await expect(second.getByRole('button', { name: 'Open review_0001.png', exact: true })).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(second.locator('img')).toHaveCount(0);
|
||||
await expect(second.locator('.image-card-actions button').last()).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(cards(page).first()).toHaveClass(/selected/);
|
||||
});
|
||||
|
||||
test('leaving during the observe releases the capture it answers', async ({ page, request }) => {
|
||||
// M4: the server made the capture; the page left before the answer came.
|
||||
await seed(request, 1);
|
||||
await open(page);
|
||||
let held;
|
||||
const sent = new Promise(resolve => { held = resolve; });
|
||||
await page.route(url => url.pathname === '/prompt_manager/image-annotations/observe', async route => {
|
||||
const response = await route.fetch();
|
||||
held(() => route.fulfill({ response }));
|
||||
});
|
||||
await apply(page).click();
|
||||
const answer = await sent;
|
||||
expect((await streams(request)).captures).toBe(1);
|
||||
await page.evaluate(() => { window.location.hash = '#/tags'; });
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Tags & categories');
|
||||
await answer();
|
||||
await expect.poll(async () => (await streams(request)).captures).toBe(0);
|
||||
});
|
||||
|
||||
test('a live re-read keeps the loaded depth and the scroll position', async ({ page, request }) => {
|
||||
// F3 ruling: a queue change re-reads as many items as Load more had shown, and the page does not jump.
|
||||
await seed(request, 45);
|
||||
await open(page);
|
||||
await expect.poll(async () => (await streams(request)).streams).toBe(1);
|
||||
await main(page).getByRole('button', { name: 'Load more images', exact: true }).click();
|
||||
await expect(cards(page)).toHaveCount(45);
|
||||
await cards(page).last().scrollIntoViewIfNeeded();
|
||||
const scrollTop = () => cards(page).first().evaluate(node => {
|
||||
for (let el = node.parentElement; el; el = el.parentElement) {
|
||||
const style = getComputedStyle(el);
|
||||
if (/(auto|scroll)/.test(style.overflowY) && el.scrollHeight > el.clientHeight) return el.scrollTop;
|
||||
}
|
||||
return document.scrollingElement.scrollTop;
|
||||
});
|
||||
const before = await scrollTop();
|
||||
expect(before).toBeGreaterThan(0);
|
||||
const reads = reviewReads(page);
|
||||
await expect(await request.post('/__test__/review/add', { data: { kind: 'monitor' } })).toBeOK();
|
||||
await expect(main(page).locator('.notice h2')).toHaveText('46 images need a decision');
|
||||
await expect(cards(page)).toHaveCount(46);
|
||||
expect(await scrollTop()).toBe(before);
|
||||
expect(reads).toEqual(['?limit=40', 'more']);
|
||||
await expect(main(page).locator('.infinite-continuation')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('teardown releases captures listeners and requests', async ({ page, request }) => {
|
||||
await seed(request, 1);
|
||||
await open(page);
|
||||
await expect.poll(async () => (await streams(request)).streams).toBe(1);
|
||||
const inFlight = new Set();
|
||||
page.on('request', item => { if (item.url().includes('/prompt_manager/review/')) inFlight.add(item); });
|
||||
page.on('requestfinished', item => inFlight.delete(item));
|
||||
page.on('requestfailed', item => inFlight.delete(item));
|
||||
// A decision whose answer never comes: leaving the page must still release its capture.
|
||||
let held;
|
||||
const sent = new Promise(resolve => { held = resolve; });
|
||||
await page.route(url => /\/decision$/.test(url.pathname), route => held(route));
|
||||
await apply(page).click();
|
||||
const route = await sent;
|
||||
expect((await streams(request)).captures).toBe(1);
|
||||
await page.evaluate(() => { window.location.hash = '#/tags'; });
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Tags & categories');
|
||||
await expect.poll(async () => (await streams(request)).captures).toBe(0);
|
||||
await route.abort().catch(() => {});
|
||||
// The server drops a closed stream at its next write: a monitor addition is one.
|
||||
await expect(await request.post('/__test__/review/add', { data: { kind: 'monitor' } })).toBeOK();
|
||||
await expect.poll(async () => (await streams(request)).streams).toBe(0);
|
||||
await expect.poll(() => inFlight.size).toBe(0);
|
||||
});
|
||||
@@ -372,7 +372,7 @@ test('Favorites is the first SAVED VIEWS row, with a real count that follows eve
|
||||
expect(await favoritesRow(page).evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE
|
||||
? `text:${node.textContent}` : `${node.localName}.${node.getAttribute('class')}`)))
|
||||
.toEqual(['svg.icon', 'text:Favorites', 'span.nav-count']);
|
||||
expect(await savedNav(page).locator('a').evaluateAll(links => links.map(link => link.dataset.route))).toEqual(['favorites']);
|
||||
expect(await savedNav(page).locator('a').evaluateAll(links => links.map(link => link.dataset.route))).toEqual(['favorites', 'review']);
|
||||
|
||||
await star(page, '1:shared/same.png');
|
||||
await expect(toast(page)).toHaveText('Added to favorites.');
|
||||
|
||||
@@ -168,15 +168,17 @@ test('a failed thumbnail read is unavailable, never zero', async ({ page }) => {
|
||||
await expect(layout.locator('.metric').nth(2).locator('strong')).toHaveText('24');
|
||||
});
|
||||
|
||||
test('ambiguous row reads the pending count; its action waits for the review page', async ({ page }) => {
|
||||
test('ambiguous row reads the pending count and Review metadata opens Needs review', async ({ page }) => {
|
||||
const layout = await openOverview(page);
|
||||
const row = layout.locator('.operation').filter({ hasText: 'ambiguous image links' });
|
||||
await expect(row.locator('h3')).toHaveText('0 ambiguous image links');
|
||||
await expect(row.locator('p')).toHaveText('Review metadata before assigning a positive / negative pair.');
|
||||
const review = row.getByRole('button', { name: 'Review metadata', exact: true });
|
||||
await expect(review).toHaveAttribute('aria-disabled', 'true');
|
||||
await review.click({ force: true });
|
||||
await expect(page).toHaveURL(`${workspace}#/system/overview`);
|
||||
await expect(review).not.toHaveAttribute('aria-disabled');
|
||||
await review.click();
|
||||
// The concept's go-review: the Needs review page.
|
||||
await expect(page).toHaveURL(`${workspace}#/review`);
|
||||
await expect(page.getByRole('heading', { name: 'Metadata review', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('View backups / Appearance / Manage sources switch sections', async ({ page }) => {
|
||||
|
||||
@@ -22,6 +22,8 @@ const routes = [
|
||||
['system', 'System', null],
|
||||
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
|
||||
['favorites', 'Favorites', null],
|
||||
// #249 PR F (ruling R5): SAVED VIEWS' Needs review, the concept's Metadata review page (its own heading).
|
||||
['review', 'Needs review', null, 'Metadata review'],
|
||||
// #249 Activity-1: the concept's Activity page in the tools-and-system group.
|
||||
['activity', 'Activity', null],
|
||||
];
|
||||
@@ -77,10 +79,10 @@ test('workspace starts with real persisted statistics without mutating data', as
|
||||
});
|
||||
|
||||
test('all shell destinations deep-link, reload and expose working existing applications', async ({ page, request }) => {
|
||||
for (const [route, label, legacy] of routes) {
|
||||
for (const [route, label, legacy, title = label] of routes) {
|
||||
await page.goto(`${workspace}#/${route}`);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
|
||||
await expect(main.getByRole('heading', { level: 1, name: title, exact: true })).toBeVisible();
|
||||
await expect(sidebarLink(page, label)).toHaveAttribute('aria-current', 'page');
|
||||
if (route === 'tags' || route === 'inspect') await expect(main.locator('.handoff')).toHaveCount(0);
|
||||
if (route === 'inspect') await expect(main.locator('#local-file')).toBeAttached();
|
||||
@@ -89,7 +91,7 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
await expect(await request.get(legacy.split('#')[0])).toBeOK();
|
||||
}
|
||||
await page.reload();
|
||||
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
|
||||
await expect(main.getByRole('heading', { level: 1, name: title, exact: true })).toBeVisible();
|
||||
}
|
||||
// D29 and #249 E3: the legacy gallery and Library are reached from Overview's tool cards, not from their pages.
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
@@ -309,9 +311,9 @@ test('overview reports loading, offers retry after failure and renders real empt
|
||||
test('small screens keep navigation and the theme accessible without horizontal page overflow', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
for (const [, label] of routes) {
|
||||
for (const [, label, , title = label] of routes) {
|
||||
await sidebarLink(page, label).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText(label);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText(title);
|
||||
}
|
||||
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
|
||||
@@ -150,6 +150,12 @@ def register_activity_commands(app, routes, api):
|
||||
scope = {
|
||||
"files": str(data.get("files", "new")),
|
||||
"output_folders": count(data.get("output_folders"), 2),
|
||||
# A real scan's count of its files in the Needs review queue.
|
||||
**(
|
||||
{"review_items": count(data["review_items"])}
|
||||
if "review_items" in data
|
||||
else {}
|
||||
),
|
||||
}
|
||||
groups = SCAN_GROUP_NAMES
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"""Owned, deterministic Needs review items for the workspace page (#249 PR F).
|
||||
|
||||
Each file is a PNG whose workflow has two samplers with their own positive
|
||||
encoders and one shared negative, so the resolver finds it ``ambiguous``. Its
|
||||
loader node carries ComfyUI's ``Infinity`` stamp, which the legacy pages'
|
||||
parser (the scan's fallback) cannot read, so nothing links the file and it is
|
||||
a Needs review case (#284: only ambiguous, unlinked files are queued). It is
|
||||
recorded through the producers' entry point (``record_review_file``). No path
|
||||
or SQL comes from a request.
|
||||
|
||||
``POST /__test__/review/reset`` also takes the special items F2 needs, queued
|
||||
after the plain ones in this order:
|
||||
|
||||
- ``conflict``: an item whose file was linked to another prompt ("Misty
|
||||
forest path") after it was queued (the next scan or monitor pass would
|
||||
remove the item); applying a sampler path then conflicts with that link.
|
||||
- ``limited``: a graph of 17 samplers, each with its own prompt, which the
|
||||
recorder stores ``limited`` (more than 16 candidates, so none are stored).
|
||||
An A1111 block with two negative lines is not queued any more: it offers
|
||||
no candidate to choose (#284).
|
||||
- ``unavailable``: a file recorded under the ``temp`` directory while it was a
|
||||
gallery root; the root is then removed again, so no current root lists it.
|
||||
|
||||
``concept: true`` (F3, the concept comparison) names and sizes the plain
|
||||
files as the concept's first three images (app.js ``images``) and gives
|
||||
their first sampler the concept's first prompt (``alpine``: its positive and
|
||||
its first negative, the one the concept's panel shows), so the panel's text
|
||||
wraps as the concept's does.
|
||||
|
||||
``POST /__test__/review/add {kind}`` adds one file while a page is open:
|
||||
``monitor`` records a new ambiguous, unlinked output from a worker thread
|
||||
through ``record_review_file`` (the monitor's own entry point; the monitor
|
||||
never queues an output it linked itself), ``file`` only writes one (a
|
||||
metadata scan records it). ``GET
|
||||
/__test__/review/links`` answers each review file's prompt and pair links, and
|
||||
``GET /__test__/review/streams`` the open change streams and captures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
POSITIVES = ("Golden dunes at dusk", "Harbor lights at night")
|
||||
NEGATIVE = "Soft negative, blur"
|
||||
# The prompt the conflict item's file is linked to after it was queued.
|
||||
FALLBACK = "Misty forest path"
|
||||
SPECIAL = ("conflict", "limited", "unavailable")
|
||||
# The concept's first three images (concept app.js `images`) and first prompt.
|
||||
CONCEPT_IMAGES = (
|
||||
("alpine_00001.png", (1536, 1152)),
|
||||
("dunes_00001.png", (1024, 1536)),
|
||||
("forest_00001.png", (1280, 1280)),
|
||||
)
|
||||
CONCEPT_POSITIVE = (
|
||||
"A remote alpine observatory at blue hour, warm light through glass, dramatic "
|
||||
"mountain peaks, cinematic composition, atmospheric mist, fine detail"
|
||||
)
|
||||
CONCEPT_NEGATIVE = "blurry, low quality, low resolution"
|
||||
# More candidates than an item stores (16): the ``limited`` item.
|
||||
LIMITED_SAMPLERS = 17
|
||||
# More than one queue page (40), so Load more has a second page.
|
||||
MAX_ITEMS = 45
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
"media_review_items",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompt_library_state",
|
||||
"prompts",
|
||||
)
|
||||
|
||||
|
||||
def _graph(
|
||||
encoder_class: str = "CLIPTextEncode",
|
||||
positives: tuple = POSITIVES,
|
||||
negative: str = NEGATIVE,
|
||||
) -> dict:
|
||||
graph = {
|
||||
# ``Infinity`` (as ComfyUI writes it): the legacy parser cannot read the
|
||||
# graph, so the scan's fallback links nothing and the file is queued.
|
||||
"1": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "m"},
|
||||
"is_changed": [float("inf")],
|
||||
},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
||||
}
|
||||
for index, text in enumerate(positives):
|
||||
encoder, sampler = str(10 + 2 * index), str(11 + 2 * index)
|
||||
graph[encoder] = {"class_type": encoder_class, "inputs": {"text": text}}
|
||||
graph[sampler] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["1", 0],
|
||||
"positive": [encoder, 0],
|
||||
"negative": ["2", 0],
|
||||
},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def _png(
|
||||
path: Path,
|
||||
colour: tuple[int, int, int],
|
||||
*,
|
||||
graph: dict | None = None,
|
||||
parameters: str | None = None,
|
||||
size: tuple[int, int] = (96, 64),
|
||||
) -> None:
|
||||
info = PngInfo()
|
||||
if parameters is not None:
|
||||
info.add_text("parameters", parameters)
|
||||
else:
|
||||
info.add_text("prompt", json.dumps(graph or _graph()))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.new("RGB", size, colour) as image:
|
||||
image.save(path, pnginfo=info)
|
||||
|
||||
|
||||
def _record(api: PromptManagerAPI, path: Path, expected: str) -> None:
|
||||
outcome = api.record_review_file(str(path))
|
||||
if outcome != expected:
|
||||
raise RuntimeError(f"{path.name} was {outcome}, not {expected}")
|
||||
|
||||
|
||||
def _stamp(path: Path, age: float) -> None:
|
||||
stamp = time.time() - age
|
||||
os.utime(path, (stamp, stamp))
|
||||
|
||||
|
||||
class _Monitor:
|
||||
"""The monitor's recorder over this server's database (one per server)."""
|
||||
|
||||
def __init__(self, api: PromptManagerAPI) -> None:
|
||||
self.api = api
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def new_output(self, path: Path) -> None:
|
||||
"""The monitor's Needs review pass for a new output, from a worker thread."""
|
||||
outcome = []
|
||||
with self.lock:
|
||||
worker = threading.Thread(
|
||||
target=lambda: outcome.append(self.api.record_review_file(str(path)))
|
||||
)
|
||||
worker.start()
|
||||
worker.join()
|
||||
if outcome != ["recorded"]:
|
||||
raise RuntimeError(f"{path.name} was {outcome}, not recorded")
|
||||
|
||||
|
||||
def _monitored_output(api: PromptManagerAPI, monitor: _Monitor, path: Path) -> None:
|
||||
_png(path, (150, 60, 40))
|
||||
monitor.new_output(path)
|
||||
|
||||
|
||||
def _linked_after_queueing(api: PromptManagerAPI, path: Path) -> None:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
_png(path, (150, 60, 40))
|
||||
_record(api, path, "recorded")
|
||||
prompt = api.db.get_prompt_by_hash(generate_prompt_hash(FALLBACK))
|
||||
prompt_id = (
|
||||
prompt["id"]
|
||||
if prompt is not None
|
||||
else api.db.save_prompt(FALLBACK, prompt_hash=generate_prompt_hash(FALLBACK))
|
||||
)
|
||||
api.db.link_image_to_prompt(prompt_id, str(path))
|
||||
|
||||
|
||||
def _unavailable(api: PromptManagerAPI, root: Path, path: Path) -> None:
|
||||
from py.config import GalleryConfig
|
||||
|
||||
roots = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [*roots, str(root / "temp")]
|
||||
try:
|
||||
_record(api, path, "recorded")
|
||||
finally:
|
||||
GalleryConfig.MONITORING_DIRECTORIES = roots
|
||||
|
||||
|
||||
def seed_review(
|
||||
api: PromptManagerAPI,
|
||||
root: Path,
|
||||
items: int,
|
||||
special: tuple = (),
|
||||
monitor: _Monitor | None = None,
|
||||
concept: bool = False,
|
||||
) -> dict:
|
||||
"""Replace this server's disposable rows and files with ``items`` queued files."""
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in _TABLES:
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
api.close_annotation_captures()
|
||||
for name in ("output", "input"):
|
||||
shutil.rmtree(root / name)
|
||||
(root / name).mkdir()
|
||||
shutil.rmtree(root / "temp" / "review", ignore_errors=True)
|
||||
output = root / "output"
|
||||
files = []
|
||||
for number in range(items):
|
||||
relative = f"review/review_{number:04}.png"
|
||||
options = {}
|
||||
if concept:
|
||||
name, size = CONCEPT_IMAGES[number % len(CONCEPT_IMAGES)]
|
||||
relative = f"review/{name}" if number < len(CONCEPT_IMAGES) else relative
|
||||
graph = _graph(
|
||||
positives=(CONCEPT_POSITIVE, POSITIVES[1]), negative=CONCEPT_NEGATIVE
|
||||
)
|
||||
options = {"graph": graph, "size": size}
|
||||
_png(output / relative, (40 + number * 4, 90, 140), **options)
|
||||
_stamp(output / relative, 60 + number)
|
||||
_record(api, output / relative, "recorded")
|
||||
files.append({"path": relative, "filename": Path(relative).name})
|
||||
for kind in SPECIAL:
|
||||
if kind not in special:
|
||||
continue
|
||||
relative = f"review/{kind}.png"
|
||||
path = (root / "temp" if kind == "unavailable" else output) / relative
|
||||
if kind == "conflict":
|
||||
_linked_after_queueing(api, path)
|
||||
elif kind == "limited":
|
||||
many = tuple(f"{POSITIVES[0]} {n}" for n in range(LIMITED_SAMPLERS))
|
||||
_png(path, (90, 90, 90), graph=_graph(positives=many))
|
||||
_record(api, path, "limited")
|
||||
else:
|
||||
_png(path, (20, 140, 90))
|
||||
_unavailable(api, root, path)
|
||||
files.append({"path": relative, "filename": path.name, "kind": kind})
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"files": files,
|
||||
"positives": list(POSITIVES),
|
||||
"negative": NEGATIVE,
|
||||
"fallback": FALLBACK,
|
||||
}
|
||||
|
||||
|
||||
def add_review_file(
|
||||
api: PromptManagerAPI, root: Path, kind: str, monitor: _Monitor
|
||||
) -> dict:
|
||||
"""One more ambiguous output: through the monitor, or only written."""
|
||||
output = root / "output"
|
||||
number = (
|
||||
len(list((output / "added").glob("*.png")))
|
||||
if (output / "added").exists()
|
||||
else 0
|
||||
)
|
||||
relative = f"added/added_{number:04}.png"
|
||||
if kind == "monitor":
|
||||
_monitored_output(api, monitor, output / relative)
|
||||
else:
|
||||
_png(output / relative, (200, 200, 40))
|
||||
api.invalidate_gallery_cache()
|
||||
return {"path": relative, "filename": Path(relative).name}
|
||||
|
||||
|
||||
_LINKS = (
|
||||
"SELECT gi.filename, p.text AS prompt, pp.id AS pair_id, pos.text AS positive, "
|
||||
"neg.text AS negative FROM generated_images gi "
|
||||
"JOIN prompts p ON p.id = gi.prompt_id "
|
||||
"LEFT JOIN image_prompt_pairs ipp ON ipp.image_id = gi.id "
|
||||
"LEFT JOIN prompt_pairs pp ON pp.id = ipp.pair_id "
|
||||
"LEFT JOIN prompts pos ON pos.id = pp.positive_prompt_id "
|
||||
"LEFT JOIN prompts neg ON neg.id = pp.negative_prompt_id "
|
||||
"ORDER BY gi.filename, gi.id, pp.id"
|
||||
)
|
||||
|
||||
|
||||
def review_links(api: PromptManagerAPI) -> list:
|
||||
"""Every file's prompt link and pair links, as plain rows."""
|
||||
conn = api.db.model.get_connection()
|
||||
return [dict(row) for row in conn.execute(_LINKS)]
|
||||
|
||||
|
||||
def register_review_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
|
||||
) -> None:
|
||||
"""Register the review fixture's commands; no path or SQL comes from a request."""
|
||||
monitors = []
|
||||
|
||||
def monitor() -> _Monitor:
|
||||
if not monitors:
|
||||
monitors.append(_Monitor(api))
|
||||
return monitors[0]
|
||||
|
||||
@routes.post("/__test__/review/reset")
|
||||
async def reset(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) - {"items", "concept", *SPECIAL}
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
items = data.get("items", 3)
|
||||
if isinstance(items, bool) or not isinstance(items, int):
|
||||
raise web.HTTPBadRequest()
|
||||
if not 0 <= items <= MAX_ITEMS:
|
||||
raise web.HTTPBadRequest()
|
||||
if any(
|
||||
not isinstance(data.get(kind, False), bool)
|
||||
for kind in (*SPECIAL, "concept")
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
special = tuple(kind for kind in SPECIAL if data.get(kind))
|
||||
manifest = await api._run_in_executor(
|
||||
seed_review,
|
||||
api,
|
||||
root,
|
||||
items,
|
||||
special,
|
||||
monitor(),
|
||||
data.get("concept", False),
|
||||
)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
@routes.post("/__test__/review/add")
|
||||
async def add(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else None
|
||||
if request.query or not isinstance(data, dict) or set(data) != {"kind"}:
|
||||
raise web.HTTPBadRequest()
|
||||
if data["kind"] not in ("monitor", "file"):
|
||||
raise web.HTTPBadRequest()
|
||||
added = await api._run_in_executor(
|
||||
add_review_file, api, root, data["kind"], monitor()
|
||||
)
|
||||
return web.json_response({"success": True, **added})
|
||||
|
||||
@routes.get("/__test__/review/links")
|
||||
async def links(request: web.Request) -> web.Response:
|
||||
if request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
return web.json_response(
|
||||
{"links": await api._run_in_executor(review_links, api)}
|
||||
)
|
||||
|
||||
@routes.get("/__test__/review/streams")
|
||||
async def streams(request: web.Request) -> web.Response:
|
||||
"""Open review change streams and held captures (teardown checks)."""
|
||||
if request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
store = api._captures
|
||||
captures = store.stats()["captures"] if store is not None else 0
|
||||
return web.json_response(
|
||||
{"streams": api.review_stream_count(), "captures": captures}
|
||||
)
|
||||
@@ -0,0 +1,788 @@
|
||||
// Run with: node --test tests/js/workspace-review-state.test.js
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createApiClient } = require('../../web/js/api-client.js');
|
||||
|
||||
// Only an absent module is tolerated, so RED is an assertion failure, not a crash.
|
||||
const source = import('../../web/js/workspace/review-state.mjs').catch(error => {
|
||||
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
|
||||
return {};
|
||||
});
|
||||
async function load() {
|
||||
const module = await source;
|
||||
assert.equal(typeof module.createReviewController, 'function', 'review-state.mjs is not implemented');
|
||||
return module;
|
||||
}
|
||||
|
||||
const EPOCH = '11111111-1111-4111-8111-111111111111';
|
||||
const SCOPE = 'c'.repeat(64);
|
||||
const SHA = 'd'.repeat(64);
|
||||
const PRINT = 'e'.repeat(64);
|
||||
const HASH_A = 'a'.repeat(64);
|
||||
const HASH_B = 'b'.repeat(64);
|
||||
const HASH_C = 'f'.repeat(64);
|
||||
const ITEMS = '/prompt_manager/review/items';
|
||||
const OBSERVE = '/prompt_manager/image-annotations/observe';
|
||||
const CAPTURE = '/prompt_manager/image-annotations/capture/';
|
||||
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
async function settle(times = 8) { for (let i = 0; i < times; i++) await flush(); }
|
||||
function response(status, body) {
|
||||
return { ok: status >= 200 && status < 300, status, statusText: `Status ${status}`, json: async () => body };
|
||||
}
|
||||
const fail = (status, code, error = `failure ${code}`, extra = {}) => response(status, { success: false, code, error, ...extra });
|
||||
|
||||
function item(id, overrides = {}) {
|
||||
const path = `dunes/${id}.png`;
|
||||
return {
|
||||
id, state: 'pending', revision: 0, created_at: `2026-10-22T10:00:0${id.length}+00:00`, filename: `${id}.png`,
|
||||
target: { root_index: 0, root_scope: SCOPE, path }, unavailable: false, candidate_count: 3, limited: false,
|
||||
url: `/prompt_manager/images/serve/${path}?root=0&root_scope=${SCOPE}`, is_video: false, ...overrides,
|
||||
};
|
||||
}
|
||||
const candidate = (hash, sampler, label, positive, negative, state = 'resolved') => ({
|
||||
hash, sampler_id: sampler, state, reason: '', label,
|
||||
positive: positive === null ? null : { text: positive }, negative: negative === null ? null : { text: negative },
|
||||
});
|
||||
function detailOf(entry, overrides = {}) {
|
||||
return {
|
||||
success: true, library_epoch: EPOCH, ...entry, total: 2, sha256: SHA, dependency_fingerprint: PRINT,
|
||||
candidates: [
|
||||
candidate(HASH_C, '9', 'No positive / Soft negative', null, 'blur', 'unresolved'),
|
||||
candidate(HASH_A, '12', 'Dunes / Soft negative', 'golden dunes', 'blur'),
|
||||
candidate(HASH_B, '28', 'Dunes / Harsh negative', 'golden dunes', 'noise'),
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const page = (items, overrides = {}) => ({ success: true, library_epoch: EPOCH, total: items.length, items, next_cursor: null, ...overrides });
|
||||
const observed = (token = 'capture-1') => ({
|
||||
success: true, library_epoch: EPOCH, target: item('one').target, filename: 'one.png', title: 'one.png', tags: [], tag_revision: 0,
|
||||
capture: { token, sha256: SHA, preview_url: CAPTURE + token, size: 10, width: 8, height: 8, media_type: 'image',
|
||||
expires_at: '2026-10-22T12:00:00+00:00', generate: { supported: true, reason: null } },
|
||||
});
|
||||
|
||||
/** Real createApiClient over a fetch whose answers come from routes['METHOD path'](call). */
|
||||
function harness(routes = {}) {
|
||||
const calls = [];
|
||||
const fetch = (url, init = {}) => {
|
||||
const call = { url, method: init.method, body: init.body === undefined ? undefined : JSON.parse(init.body) };
|
||||
calls.push(call);
|
||||
const path = url.startsWith(CAPTURE) ? CAPTURE : url.split('?')[0];
|
||||
const route = routes[`${call.method} ${path}`];
|
||||
return new Promise((resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
|
||||
Promise.resolve().then(() => (route ? route(call) : response(200, { success: true }))).then(resolve, reject);
|
||||
});
|
||||
};
|
||||
return {
|
||||
api: createApiClient(fetch), calls,
|
||||
of: (method, prefix) => calls.filter(call => call.method === method && call.url.startsWith(prefix)),
|
||||
};
|
||||
}
|
||||
const QUEUE = [item('one'), item('two')];
|
||||
function routes(extra = {}) {
|
||||
return {
|
||||
[`GET ${ITEMS}`]: () => response(200, page(QUEUE)),
|
||||
[`GET ${ITEMS}/one`]: () => response(200, detailOf(QUEUE[0])),
|
||||
[`GET ${ITEMS}/two`]: () => response(200, detailOf(QUEUE[1], { candidates: [], limited: true, candidate_count: 0 })),
|
||||
[`POST ${OBSERVE}`]: () => response(200, observed()),
|
||||
[`POST ${ITEMS}/one/decision`]: call => response(200, { success: true, library_epoch: EPOCH, total: 1,
|
||||
item: { ...QUEUE[0], state: call.body.choice === 'unresolved' ? 'unresolved' : 'selected', revision: 1 } }),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
async function loaded(extra) {
|
||||
const { createReviewController } = await load();
|
||||
const h = harness(routes(extra));
|
||||
const c = createReviewController({ api: h.api });
|
||||
await c.load();
|
||||
await settle();
|
||||
return { c, h };
|
||||
}
|
||||
|
||||
test('notice titles for 0, 1 and 3', async () => {
|
||||
const { noticeTitle } = await load();
|
||||
assert.equal(noticeTitle(0), 'No images need a decision');
|
||||
assert.equal(noticeTitle(1), '1 image needs a decision');
|
||||
assert.equal(noticeTitle(3), '3 images need a decision');
|
||||
assert.equal(noticeTitle(1234), '1,234 images need a decision');
|
||||
});
|
||||
|
||||
test('loads pending items and the exact count', async () => {
|
||||
const { c, h } = await loaded();
|
||||
assert.equal(c.state.status, 'ready');
|
||||
assert.equal(c.state.total, 2);
|
||||
assert.deepEqual(c.state.items.map(entry => entry.id), ['one', 'two']);
|
||||
const [list] = h.of('GET', ITEMS);
|
||||
assert.equal(list.url, `${ITEMS}?limit=40`);
|
||||
// The first item is selected, as the concept selects its first queue image.
|
||||
assert.equal(c.state.selectedId, 'one');
|
||||
assert.ok(Object.isFrozen(c.state));
|
||||
});
|
||||
|
||||
test('selecting a card loads detail and option labels, first workable preselected, unusable disabled', async () => {
|
||||
const { c } = await loaded();
|
||||
assert.equal(c.state.detailStatus, 'ready');
|
||||
assert.deepEqual(c.state.options.map(option => [option.label, option.disabled]), [
|
||||
['Sampler 9 · No positive / Soft negative', true],
|
||||
['Sampler 12 · Dunes / Soft negative', false],
|
||||
['Sampler 28 · Dunes / Harsh negative', false],
|
||||
['Leave unresolved', false],
|
||||
]);
|
||||
assert.equal(c.state.options.at(-1).value, 'unresolved');
|
||||
assert.equal(c.state.choice, HASH_A);
|
||||
assert.deepEqual(c.state.prompts, { positive: { status: 'resolved', text: 'golden dunes' }, negative: { status: 'resolved', text: 'blur' } });
|
||||
c.choose(HASH_C);
|
||||
assert.equal(c.state.choice, HASH_A, 'a disabled option cannot be chosen');
|
||||
c.choose(HASH_B);
|
||||
assert.equal(c.state.prompts.negative.text, 'noise');
|
||||
c.choose('unresolved');
|
||||
assert.equal(c.state.prompts.positive.status, 'ambiguous');
|
||||
await c.select('two');
|
||||
await settle();
|
||||
assert.equal(c.state.selectedId, 'two');
|
||||
assert.deepEqual(c.state.options.map(option => option.label), ['Leave unresolved']);
|
||||
assert.equal(c.state.choice, 'unresolved');
|
||||
assert.match(c.state.warning, /too many sampler paths/i);
|
||||
});
|
||||
|
||||
test('apply sends the selected candidate hash or unresolved with a fresh capture', async () => {
|
||||
const { c, h } = await loaded();
|
||||
c.choose(HASH_B);
|
||||
const done = await c.apply();
|
||||
assert.equal(done, true);
|
||||
const [observe] = h.of('POST', OBSERVE);
|
||||
assert.deepEqual(observe.body, { target: QUEUE[0].target });
|
||||
const [decision] = h.of('POST', `${ITEMS}/one/decision`);
|
||||
assert.deepEqual(decision.body, { expected_epoch: EPOCH, expected_revision: 0, capture_token: 'capture-1', sha256: SHA,
|
||||
dependency_fingerprint: PRINT, choice: HASH_B });
|
||||
assert.equal(c.state.phase, 'done');
|
||||
assert.equal(c.state.total, 1);
|
||||
assert.equal(c.state.toast, 'Association accepted.');
|
||||
// The server releases a used capture itself.
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 0);
|
||||
|
||||
const again = await loaded();
|
||||
again.c.choose('unresolved');
|
||||
assert.equal(await again.c.apply(), true);
|
||||
const [unresolved] = again.h.of('POST', `${ITEMS}/one/decision`);
|
||||
assert.equal(unresolved.body.choice, 'unresolved');
|
||||
assert.equal(unresolved.body.capture_token, 'capture-1');
|
||||
assert.equal(again.c.state.toast, 'Left unresolved.');
|
||||
});
|
||||
|
||||
test('an unavailable file can only be left unresolved, without a capture', async () => {
|
||||
const gone = item('one', { target: null, unavailable: true, url: null });
|
||||
const { c, h } = await loaded({
|
||||
[`GET ${ITEMS}`]: () => response(200, page([gone])),
|
||||
[`GET ${ITEMS}/one`]: () => response(200, detailOf(gone)),
|
||||
});
|
||||
assert.ok(c.state.options.slice(0, -1).every(option => option.disabled));
|
||||
assert.equal(c.state.choice, 'unresolved');
|
||||
assert.match(c.state.warning, /not under a current image source/i);
|
||||
assert.equal(await c.apply(), true);
|
||||
assert.equal(h.of('POST', OBSERVE).length, 0);
|
||||
const [decision] = h.of('POST', `${ITEMS}/one/decision`);
|
||||
assert.equal(decision.body.capture_token, null);
|
||||
assert.equal(decision.body.sha256, SHA);
|
||||
});
|
||||
|
||||
test('association conflict keeps the selection and shows the inline warning', async () => {
|
||||
const message = 'This image is already linked to another prompt or negative, so this sampler path cannot be attached safely.';
|
||||
const { c, h } = await loaded({ [`POST ${ITEMS}/one/decision`]: () => fail(409, 'association-conflict', message) });
|
||||
assert.equal(await c.apply(), false);
|
||||
assert.equal(c.state.selectedId, 'one');
|
||||
assert.equal(c.state.choice, HASH_A);
|
||||
assert.equal(c.state.phase, 'idle');
|
||||
assert.equal(c.state.warning, message);
|
||||
// The refused decision's capture is released.
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 1);
|
||||
assert.equal(h.of('GET', `${ITEMS}?`).length, 1, 'no reload');
|
||||
});
|
||||
|
||||
test('storage-changed requires reload', async () => {
|
||||
let lists = 0;
|
||||
const { c, h } = await loaded({
|
||||
[`GET ${ITEMS}`]: () => { lists += 1; return response(200, page(QUEUE)); },
|
||||
[`POST ${ITEMS}/one/decision`]: () => fail(409, 'storage-changed', 'Prompt storage was restored; reload the review queue.'),
|
||||
});
|
||||
assert.equal(await c.apply(), false);
|
||||
await settle();
|
||||
assert.equal(lists, 2, 'the queue is read again under the new epoch');
|
||||
assert.equal(c.state.warning, 'Prompt storage was restored; reload the review queue.');
|
||||
assert.equal(c.state.canRetry, false);
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 1);
|
||||
});
|
||||
|
||||
test('lost response keeps the selection and retry resends once', async () => {
|
||||
let attempts = 0;
|
||||
const { c, h } = await loaded({
|
||||
[`POST ${ITEMS}/one/decision`]: call => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return Promise.reject(new TypeError('Failed to fetch'));
|
||||
return response(200, { success: true, library_epoch: EPOCH, total: 1, item: { ...QUEUE[0], state: 'selected', revision: 1 } });
|
||||
},
|
||||
});
|
||||
assert.equal(await c.apply(), false);
|
||||
assert.equal(c.state.selectedId, 'one');
|
||||
assert.equal(c.state.canRetry, true);
|
||||
assert.match(c.state.warning, /may not have been saved/i);
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 0, 'the capture is kept for the retry');
|
||||
const [first] = h.of('POST', `${ITEMS}/one/decision`);
|
||||
assert.equal(await c.retry(), true);
|
||||
const sent = h.of('POST', `${ITEMS}/one/decision`);
|
||||
assert.equal(sent.length, 2);
|
||||
assert.deepEqual(sent[1].body, first.body, 'the identical decision is resent');
|
||||
assert.equal(h.of('POST', OBSERVE).length, 1, 'no second capture');
|
||||
assert.equal(await c.retry(), false, 'nothing left to resend');
|
||||
assert.equal(h.of('POST', `${ITEMS}/one/decision`).length, 2);
|
||||
});
|
||||
|
||||
test('a retry answered by review-conflict reloads the queue', async () => {
|
||||
let attempts = 0, lists = 0;
|
||||
const { c } = await loaded({
|
||||
[`GET ${ITEMS}`]: () => { lists += 1; return response(200, page(lists === 1 ? QUEUE : [QUEUE[1]])); },
|
||||
[`POST ${ITEMS}/one/decision`]: () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return Promise.reject(new TypeError('Failed to fetch'));
|
||||
return fail(409, 'review-conflict', 'This item was decided or changed since it was opened. Reload the queue.');
|
||||
},
|
||||
});
|
||||
await c.apply();
|
||||
assert.equal(await c.retry(), false);
|
||||
await settle();
|
||||
assert.equal(lists, 2);
|
||||
assert.equal(c.state.selectedId, 'two');
|
||||
});
|
||||
|
||||
test('a review-conflict on a still pending item reads its detail again', async () => {
|
||||
let details = 0, decisions = 0;
|
||||
const { c, h } = await loaded({
|
||||
[`GET ${ITEMS}/one`]: () => { details += 1; return response(200, detailOf({ ...QUEUE[0], revision: details - 1 })); },
|
||||
[`POST ${ITEMS}/one/decision`]: () => {
|
||||
decisions += 1;
|
||||
return decisions === 1 ? fail(409, 'review-conflict', 'This item was decided or changed since it was opened. Reload the queue.')
|
||||
: response(200, { success: true, library_epoch: EPOCH, total: 1, item: { ...QUEUE[0], state: 'selected', revision: 2 } });
|
||||
},
|
||||
});
|
||||
assert.equal(await c.apply(), false);
|
||||
await settle();
|
||||
assert.equal(details, 2, 'the item is read again');
|
||||
assert.equal(c.state.detail.revision, 1);
|
||||
assert.equal(c.state.warning, 'This item was decided or changed since it was opened. Reload the queue.');
|
||||
assert.equal(await c.apply(), true);
|
||||
assert.equal(h.of('POST', `${ITEMS}/one/decision`).at(-1).body.expected_revision, 1);
|
||||
});
|
||||
|
||||
test('leave for later writes nothing', async () => {
|
||||
const { c, h } = await loaded();
|
||||
c.leave();
|
||||
assert.equal(c.state.phase, 'done');
|
||||
assert.equal(c.state.toast, null);
|
||||
assert.equal(h.of('POST', '/').length, 0);
|
||||
assert.equal(h.of('DELETE', '/').length, 0);
|
||||
});
|
||||
|
||||
test('load more pages with the cursor and keeps the selection', async () => {
|
||||
const more = [item('three')];
|
||||
const { c, h } = await loaded({
|
||||
[`GET ${ITEMS}`]: call => response(200, call.url.includes('cursor=')
|
||||
? page(more, { total: 3 }) : page(QUEUE, { total: 3, next_cursor: 'abc_-1' })),
|
||||
});
|
||||
assert.equal(c.state.nextCursor, 'abc_-1');
|
||||
await c.loadMore();
|
||||
assert.equal(h.of('GET', ITEMS).at(-1).url, `${ITEMS}?limit=40&cursor=abc_-1`);
|
||||
assert.deepEqual(c.state.items.map(entry => entry.id), ['one', 'two', 'three']);
|
||||
assert.equal(c.state.nextCursor, null);
|
||||
assert.equal(c.state.selectedId, 'one');
|
||||
});
|
||||
|
||||
test('a failed queue read is an error state and load retries', async () => {
|
||||
let lists = 0;
|
||||
const { c } = await loaded({ [`GET ${ITEMS}`]: () => { lists += 1; return lists === 1 ? fail(500, 'review-failed') : response(200, page(QUEUE)); } });
|
||||
assert.equal(c.state.status, 'error');
|
||||
await c.load();
|
||||
await settle();
|
||||
assert.equal(c.state.status, 'ready');
|
||||
});
|
||||
|
||||
test('tag save toast carries the review suffix and the item stays pending', async () => {
|
||||
const { c, h } = await loaded({
|
||||
['GET /prompt_manager/autotag/models']: () => response(200, { success: true, models: {}, default_prompt: 'x',
|
||||
wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, suggestion_limit: 10 }),
|
||||
['PUT /prompt_manager/image-annotations/tags']: call => response(200, { success: true, library_epoch: EPOCH, sha256: SHA,
|
||||
tags: call.body.tags, tag_revision: 1, replayed: false }),
|
||||
});
|
||||
const { createImageTaggingController } = await import('../../web/js/workspace/image-tagging-state.mjs');
|
||||
const before = h.calls.filter(call => call.url.startsWith(ITEMS)).length;
|
||||
// The Tag dialog opened from Needs review is the shared controller with the review source.
|
||||
const tagging = createImageTaggingController({ api: h.api, target: QUEUE[0].target, source: 'review' });
|
||||
assert.equal(tagging.state.showScopeNote, true);
|
||||
await tagging.open();
|
||||
await settle();
|
||||
tagging.setManual('dunes');
|
||||
await tagging.save();
|
||||
await settle();
|
||||
assert.equal(tagging.state.toast, '1 tags saved for one.png. Prompt association still awaits review.');
|
||||
tagging.dispose();
|
||||
assert.equal(h.calls.filter(call => call.url.startsWith(ITEMS)).length, before, 'a tag save neither decides nor re-reads the queue');
|
||||
assert.deepEqual(c.state.items.map(entry => [entry.id, entry.state]), [['one', 'pending'], ['two', 'pending']]);
|
||||
assert.equal(c.state.phase, 'idle');
|
||||
});
|
||||
|
||||
test('dispose releases a kept capture and stops reads', async () => {
|
||||
const { c, h } = await loaded({ [`POST ${ITEMS}/one/decision`]: () => Promise.reject(new TypeError('Failed to fetch')) });
|
||||
await c.apply();
|
||||
c.dispose();
|
||||
await settle();
|
||||
const released = h.of('DELETE', CAPTURE);
|
||||
assert.equal(released.length, 1);
|
||||
assert.equal(released[0].url, CAPTURE + 'capture-1');
|
||||
assert.equal(await c.retry(), false);
|
||||
});
|
||||
|
||||
// F2: an open page re-reads its queue when the server says it changed (the review change stream).
|
||||
function refreshOf(c) {
|
||||
assert.equal(typeof c.refresh, 'function', 'refresh is not implemented');
|
||||
return c.refresh();
|
||||
}
|
||||
test('refresh re-reads the queue quietly and keeps the selection and choice', async () => {
|
||||
let queue = QUEUE;
|
||||
const { c, h } = await loaded({ [`GET ${ITEMS}`]: () => response(200, page(queue)) });
|
||||
c.choose(HASH_B);
|
||||
const statuses = [];
|
||||
c.subscribe(state => statuses.push(state.status));
|
||||
queue = [...QUEUE, item('three')];
|
||||
await refreshOf(c);
|
||||
await settle();
|
||||
assert.deepEqual(c.state.items.map(entry => entry.id), ['one', 'two', 'three']);
|
||||
assert.equal(c.state.total, 3);
|
||||
assert.equal(c.state.selectedId, 'one');
|
||||
assert.equal(c.state.choice, HASH_B);
|
||||
assert.equal(c.state.warning, '');
|
||||
assert.ok(statuses.every(status => status === 'ready'), `no loading state: ${statuses}`);
|
||||
assert.equal(h.of('GET', `${ITEMS}/one`).length, 1, 'the open item is not read again');
|
||||
});
|
||||
|
||||
test('refreshes asked for during a refresh collapse into one more read', async () => {
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
let lists = 0;
|
||||
const { c, h } = await loaded({ [`GET ${ITEMS}`]: async () => {
|
||||
lists += 1;
|
||||
if (lists === 2) await held;
|
||||
return response(200, page(QUEUE));
|
||||
} });
|
||||
const first = refreshOf(c);
|
||||
await settle();
|
||||
refreshOf(c);
|
||||
refreshOf(c);
|
||||
release();
|
||||
await first;
|
||||
await settle();
|
||||
assert.equal(h.of('GET', `${ITEMS}?`).length, 3, 'the load, the refresh and one collapsed re-read');
|
||||
assert.equal(c.state.status, 'ready');
|
||||
});
|
||||
|
||||
test('a refresh after the open item was decided elsewhere opens the next one and says so', async () => {
|
||||
let queue = QUEUE;
|
||||
const { c, h } = await loaded({
|
||||
[`GET ${ITEMS}`]: () => response(200, page(queue)),
|
||||
[`GET ${ITEMS}/three`]: () => response(200, detailOf(item('three'))),
|
||||
});
|
||||
queue = [item('three')];
|
||||
await refreshOf(c);
|
||||
await settle();
|
||||
assert.equal(c.state.selectedId, 'three');
|
||||
assert.equal(c.state.detailStatus, 'ready');
|
||||
assert.equal(c.state.warning, 'one.png was decided elsewhere, so the next image is open.');
|
||||
assert.equal(h.of('GET', `${ITEMS}/three`).length, 1);
|
||||
});
|
||||
|
||||
test('a refresh to an empty queue clears the selection', async () => {
|
||||
let queue = QUEUE;
|
||||
const { c } = await loaded({ [`GET ${ITEMS}`]: () => response(200, page(queue)) });
|
||||
queue = [];
|
||||
await refreshOf(c);
|
||||
await settle();
|
||||
assert.equal(c.state.total, 0);
|
||||
assert.deepEqual(c.state.items, []);
|
||||
assert.equal(c.state.selectedId, null);
|
||||
assert.equal(c.state.status, 'ready');
|
||||
});
|
||||
|
||||
test('a failed refresh keeps the queue and says so', async () => {
|
||||
let lists = 0;
|
||||
const { c } = await loaded({ [`GET ${ITEMS}`]: () => { lists += 1; return lists === 1 ? response(200, page(QUEUE)) : fail(500, 'review-failed'); } });
|
||||
await refreshOf(c);
|
||||
await settle();
|
||||
assert.equal(c.state.status, 'ready');
|
||||
assert.deepEqual(c.state.items.map(entry => entry.id), ['one', 'two']);
|
||||
assert.equal(c.state.warning, 'The review queue could not be read again. Reload the page to see the latest images.');
|
||||
});
|
||||
|
||||
test('dispose during a decision releases its capture', async () => {
|
||||
const { c, h } = await loaded({ [`POST ${ITEMS}/one/decision`]: () => new Promise(() => {}) });
|
||||
const applying = c.apply();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'applying');
|
||||
c.dispose();
|
||||
assert.equal(await applying, false);
|
||||
await settle();
|
||||
assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + 'capture-1']);
|
||||
await refreshOf(c);
|
||||
assert.equal(h.of('GET', `${ITEMS}?`).length, 1, 'a disposed controller reads nothing');
|
||||
});
|
||||
|
||||
test('a change heard during the first read re-reads once after it, and a failed first read stays an error', async () => {
|
||||
const { createReviewController } = await load();
|
||||
let release;
|
||||
let lists = 0;
|
||||
const h = harness(routes({ [`GET ${ITEMS}`]: async () => {
|
||||
lists += 1;
|
||||
if (lists === 1) await new Promise(resolve => { release = resolve; });
|
||||
return response(200, page(QUEUE));
|
||||
} }));
|
||||
const c = createReviewController({ api: h.api });
|
||||
const first = c.load();
|
||||
await settle();
|
||||
refreshOf(c);
|
||||
assert.equal(lists, 1, 'the first read is not interrupted');
|
||||
release();
|
||||
await first;
|
||||
await settle();
|
||||
assert.equal(lists, 2, 'one re-read after the first read');
|
||||
assert.equal(c.state.status, 'ready');
|
||||
|
||||
const failing = harness(routes({ [`GET ${ITEMS}`]: () => fail(500, 'review-failed') }));
|
||||
const broken = createReviewController({ api: failing.api });
|
||||
const reading = broken.load();
|
||||
refreshOf(broken);
|
||||
await reading;
|
||||
await settle();
|
||||
assert.equal(broken.state.status, 'error');
|
||||
assert.equal(failing.of('GET', `${ITEMS}?`).length, 1, 'Retry is the way back, not a re-read');
|
||||
});
|
||||
|
||||
// F3 rulings: a live re-read keeps the loaded depth, and the stream's `ready` reads nothing when the first read is current.
|
||||
|
||||
/** A keyset pager over `source()`: pages of 40 with an index cursor, each answering `version()`. */
|
||||
function pager(source, version = () => 0, { onPage } = {}) {
|
||||
return call => {
|
||||
const query = new URL(call.url, 'http://x').searchParams;
|
||||
const cursor = query.get('cursor');
|
||||
const all = source();
|
||||
const from = cursor ? Number(cursor.slice(1)) : 0;
|
||||
const answer = onPage?.(cursor);
|
||||
if (answer) return answer;
|
||||
const items = all.slice(from, from + 40);
|
||||
const next = from + 40 < all.length ? `c${from + 40}` : null;
|
||||
return response(200, page(items, { total: all.length, next_cursor: next, review_revision: version() }));
|
||||
};
|
||||
}
|
||||
const many = (count, prefix = 'i') => Array.from({ length: count }, (_, index) => item(`${prefix}${String(index).padStart(3, '0')}`));
|
||||
function syncOf(c, version) {
|
||||
assert.equal(typeof c.sync, 'function', 'sync is not implemented');
|
||||
return c.sync(version);
|
||||
}
|
||||
const version = revision => ({ library_epoch: EPOCH, review_revision: revision });
|
||||
|
||||
test('a refresh keeps the loaded depth: it re-reads as many items as were loaded', async () => {
|
||||
let queue = many(100);
|
||||
const { c, h } = await loaded({ [`GET ${ITEMS}`]: pager(() => queue) });
|
||||
await c.loadMore();
|
||||
assert.equal(c.state.items.length, 80);
|
||||
const before = h.of('GET', `${ITEMS}?`).length;
|
||||
queue = [...queue, item('late')];
|
||||
await refreshOf(c);
|
||||
await settle();
|
||||
assert.equal(c.state.items.length, 80, 'the Load more page is kept');
|
||||
assert.deepEqual(c.state.items.map(entry => entry.id), queue.slice(0, 80).map(entry => entry.id));
|
||||
assert.equal(c.state.total, 101);
|
||||
assert.equal(c.state.nextCursor, 'c80', 'Load more continues after the kept depth');
|
||||
assert.deepEqual(h.of('GET', `${ITEMS}?`).slice(before).map(call => call.url),
|
||||
[`${ITEMS}?limit=40`, `${ITEMS}?limit=40&cursor=c40`]);
|
||||
assert.equal(c.state.selectedId, 'i000');
|
||||
});
|
||||
|
||||
test('a refresh after decisions elsewhere fills the loaded depth from what is left', async () => {
|
||||
let queue = many(85);
|
||||
const { c } = await loaded({ [`GET ${ITEMS}`]: pager(() => queue) });
|
||||
await c.loadMore();
|
||||
await c.loadMore();
|
||||
assert.equal(c.state.items.length, 85);
|
||||
queue = queue.filter((_, index) => index % 10 !== 5);
|
||||
await refreshOf(c);
|
||||
await settle();
|
||||
assert.equal(c.state.items.length, queue.length);
|
||||
assert.equal(c.state.nextCursor, null);
|
||||
});
|
||||
|
||||
test('a deep refresh whose next page meets a restore reads again from the first page', async () => {
|
||||
let restored = false;
|
||||
let queue = many(60);
|
||||
const { c, h } = await loaded({ [`GET ${ITEMS}`]: pager(() => queue, () => 0, {
|
||||
onPage: cursor => {
|
||||
if (cursor && restored) { restored = false; return fail(409, 'storage-changed', 'The library was restored.'); }
|
||||
return null;
|
||||
},
|
||||
}) });
|
||||
await c.loadMore();
|
||||
const before = h.of('GET', `${ITEMS}?`).length;
|
||||
restored = true;
|
||||
queue = many(60, 'r');
|
||||
await refreshOf(c);
|
||||
await settle();
|
||||
assert.deepEqual(h.of('GET', `${ITEMS}?`).slice(before).map(call => call.url),
|
||||
[`${ITEMS}?limit=40`, `${ITEMS}?limit=40&cursor=c40`, `${ITEMS}?limit=40`, `${ITEMS}?limit=40&cursor=c40`]);
|
||||
assert.equal(c.state.items.length, 60);
|
||||
assert.equal(c.state.items[0].id, 'r000');
|
||||
assert.notEqual(c.state.warning, 'The review queue could not be read again. Reload the page to see the latest images.',
|
||||
'a restore met mid-read is not a failed re-read');
|
||||
assert.equal(c.state.status, 'ready');
|
||||
});
|
||||
|
||||
test('ready naming the version the first read answered reads nothing more', async () => {
|
||||
const { c, h } = await loaded({ [`GET ${ITEMS}`]: pager(() => QUEUE, () => 4) });
|
||||
await syncOf(c, version(4));
|
||||
await settle();
|
||||
assert.equal(h.of('GET', `${ITEMS}?`).length, 1, 'one list read per visit');
|
||||
assert.equal(h.of('GET', `${ITEMS}/one`).length, 1);
|
||||
});
|
||||
|
||||
test('ready naming a newer version, another epoch or no version re-reads once', async () => {
|
||||
const { c, h } = await loaded({ [`GET ${ITEMS}`]: pager(() => QUEUE, () => 4) });
|
||||
await syncOf(c, version(5));
|
||||
await settle();
|
||||
assert.equal(h.of('GET', `${ITEMS}?`).length, 2);
|
||||
await syncOf(c, { library_epoch: '22222222-2222-4222-8222-222222222222', review_revision: 4 });
|
||||
await settle();
|
||||
assert.equal(h.of('GET', `${ITEMS}?`).length, 3);
|
||||
await syncOf(c, {});
|
||||
await settle();
|
||||
assert.equal(h.of('GET', `${ITEMS}?`).length, 4, 'an unknown version is read, as before versions existed');
|
||||
});
|
||||
|
||||
test('ready heard during the first read re-reads only when that read is older', async () => {
|
||||
const { createReviewController } = await load();
|
||||
for (const [readRevision, readyRevision, reads] of [[4, 4, 1], [5, 4, 1], [4, 5, 2]]) {
|
||||
let release;
|
||||
let lists = 0;
|
||||
const answer = pager(() => QUEUE, () => (lists === 1 ? readRevision : readyRevision));
|
||||
const h = harness(routes({ [`GET ${ITEMS}`]: async call => {
|
||||
lists += 1;
|
||||
if (lists === 1) await new Promise(resolve => { release = resolve; });
|
||||
return answer(call);
|
||||
} }));
|
||||
const c = createReviewController({ api: h.api });
|
||||
const first = c.load();
|
||||
await settle();
|
||||
syncOf(c, version(readyRevision));
|
||||
release();
|
||||
await first;
|
||||
await settle();
|
||||
assert.equal(lists, reads, `first read r${readRevision}, ready r${readyRevision}`);
|
||||
c.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('a change heard during the first read still re-reads after a current ready', async () => {
|
||||
const { createReviewController } = await load();
|
||||
let release;
|
||||
let lists = 0;
|
||||
const h = harness(routes({ [`GET ${ITEMS}`]: async call => {
|
||||
lists += 1;
|
||||
if (lists === 1) await new Promise(resolve => { release = resolve; });
|
||||
return pager(() => QUEUE, () => 4)(call);
|
||||
} }));
|
||||
const c = createReviewController({ api: h.api });
|
||||
const first = c.load();
|
||||
await settle();
|
||||
syncOf(c, version(4));
|
||||
refreshOf(c);
|
||||
release();
|
||||
await first;
|
||||
await settle();
|
||||
assert.equal(lists, 2);
|
||||
});
|
||||
|
||||
// Final review round (I1, I2, M3–M5, M7).
|
||||
test('I1: a change heard during a refused Apply is re-read once the phase is idle again', async () => {
|
||||
let queue = [item('one')], releaseDecision;
|
||||
const { c, h } = await loaded({
|
||||
[`GET ${ITEMS}`]: () => response(200, page(queue, { review_revision: queue.length })),
|
||||
[`POST ${ITEMS}/one/decision`]: () => new Promise(resolve => {
|
||||
releaseDecision = () => resolve(fail(409, 'association-conflict', 'conflict'));
|
||||
}),
|
||||
});
|
||||
const applying = c.apply();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'applying');
|
||||
queue = [item('one'), item('two')]; // the monitor records an item while the decision is in flight
|
||||
c.refresh();
|
||||
await settle();
|
||||
assert.equal(h.of('GET', `${ITEMS}?`).length, 1, 'no read while the decision is in flight');
|
||||
releaseDecision();
|
||||
await applying;
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'idle');
|
||||
assert.deepEqual(c.state.items.map(entry => entry.id), ['one', 'two']);
|
||||
assert.equal(c.state.total, 2);
|
||||
assert.equal(c.state.warning, 'conflict', 'the refusal stays the warning');
|
||||
assert.equal(h.of('GET', `${ITEMS}?`).length, 2, 'exactly one owed re-read');
|
||||
});
|
||||
|
||||
test('I1: a change heard during an Apply whose observe is refused is re-read too', async () => {
|
||||
let queue = [item('one')], releaseObserve;
|
||||
const { c } = await loaded({
|
||||
[`GET ${ITEMS}`]: () => response(200, page(queue)),
|
||||
[`POST ${OBSERVE}`]: () => new Promise(resolve => {
|
||||
releaseObserve = () => resolve(fail(409, 'capture-capacity', 'busy'));
|
||||
}),
|
||||
});
|
||||
const applying = c.apply();
|
||||
await settle();
|
||||
queue = [item('one'), item('two')];
|
||||
c.refresh();
|
||||
releaseObserve();
|
||||
await applying;
|
||||
await settle();
|
||||
assert.deepEqual(c.state.items.map(entry => entry.id), ['one', 'two']);
|
||||
});
|
||||
|
||||
const forty = Array.from({ length: 41 }, (_, i) => item(`i${String(i).padStart(2, '0')}`));
|
||||
function deepRoutes(state) {
|
||||
return {
|
||||
[`GET ${ITEMS}`]: call => {
|
||||
const cursor = new URL(call.url, 'http://x').searchParams.get('cursor');
|
||||
if (cursor) {
|
||||
if (state.moreGate) return state.moreGate.then(() => state.more ?? response(200, page([forty[40]], { total: 41 })));
|
||||
return state.more ?? response(200, page([forty[40]], { total: 41 }));
|
||||
}
|
||||
const body = response(200, page(state.first, { total: state.first.length + 1, review_revision: state.revision ?? 1, next_cursor: 'cur' }));
|
||||
return state.gate ? state.gate.then(() => state.fail ?? body) : body;
|
||||
},
|
||||
[`GET ${ITEMS}/i00`]: () => response(200, detailOf(forty[0])),
|
||||
};
|
||||
}
|
||||
|
||||
test('I2: Load more during a re-read waits for it and continues from the re-read queue', async () => {
|
||||
const state = { first: forty.slice(0, 40) };
|
||||
const { c } = await loaded(deepRoutes(state));
|
||||
// Another tab decides i05; the re-read is slow and the user clicks Load more meanwhile.
|
||||
let open;
|
||||
state.first = forty.slice(0, 40).filter(entry => entry.id !== 'i05');
|
||||
state.gate = new Promise(resolve => { open = resolve; });
|
||||
const refreshing = c.refresh();
|
||||
await settle();
|
||||
const more = c.loadMore();
|
||||
await settle();
|
||||
assert.equal(c.state.more, 'loading', 'the click shows as loading while the re-read runs');
|
||||
open();
|
||||
await Promise.all([refreshing, more]);
|
||||
await settle();
|
||||
const ids = c.state.items.map(entry => entry.id);
|
||||
assert.ok(!ids.includes('i05'), 'the re-read was applied');
|
||||
assert.equal(ids.at(-1), 'i40', 'Load more appended the next page after the re-read');
|
||||
assert.equal(c.state.more, 'idle');
|
||||
});
|
||||
|
||||
test('I2: a re-read started during Load more takes over the click and reads one page deeper', async () => {
|
||||
const state = { first: forty.slice(0, 40) };
|
||||
const { c, h } = await loaded(deepRoutes(state));
|
||||
let openMore;
|
||||
state.moreGate = new Promise(resolve => { openMore = resolve; });
|
||||
const more = c.loadMore();
|
||||
await settle();
|
||||
state.moreGate = null;
|
||||
state.first = forty.slice(0, 40).filter(entry => entry.id !== 'i05');
|
||||
await c.refresh();
|
||||
openMore();
|
||||
await more;
|
||||
await settle();
|
||||
const ids = c.state.items.map(entry => entry.id);
|
||||
assert.ok(!ids.includes('i05'));
|
||||
assert.equal(ids.at(-1), 'i40', 'the aborted Load more page is part of the re-read');
|
||||
assert.equal(c.state.more, 'idle');
|
||||
assert.equal(h.of('GET', `${ITEMS}?`).length, 4, 'load, the aborted more, and the re-read of two pages');
|
||||
});
|
||||
|
||||
test('M7: a failed re-read that took over Load more clears its loading state', async () => {
|
||||
const state = { first: forty.slice(0, 40) };
|
||||
const { c } = await loaded(deepRoutes(state));
|
||||
state.moreGate = new Promise(() => {});
|
||||
c.loadMore();
|
||||
await settle();
|
||||
assert.equal(c.state.more, 'loading');
|
||||
state.moreGate = null;
|
||||
state.gate = Promise.resolve();
|
||||
state.fail = fail(500, 'review-failed');
|
||||
await c.refresh();
|
||||
await settle();
|
||||
assert.equal(c.state.more, 'idle', 'Load more can be clicked again');
|
||||
assert.equal(c.state.warning, COPY_REFRESH_FAILED);
|
||||
});
|
||||
const COPY_REFRESH_FAILED = 'The review queue could not be read again. Reload the page to see the latest images.';
|
||||
|
||||
test('M3: Apply fences on the epoch of the detail it decides', async () => {
|
||||
const OTHER = '22222222-2222-4222-8222-222222222222';
|
||||
const { c, h } = await loaded({ [`POST ${OBSERVE}`]: () => response(200, { ...observed(), library_epoch: OTHER }) });
|
||||
assert.equal(await c.apply(), false);
|
||||
await settle();
|
||||
assert.equal(h.of('POST', `${ITEMS}/one/decision`).length, 0, 'a restored storage sends no decision');
|
||||
assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + 'capture-1']);
|
||||
assert.equal(h.of('GET', `${ITEMS}?`).length, 2, 'the queue is read again');
|
||||
assert.equal(h.of('GET', `${ITEMS}/one`).length, 2, 'the open item is read again under the new storage');
|
||||
assert.equal(c.state.phase, 'idle');
|
||||
});
|
||||
|
||||
test('M3: the decision carries the detail epoch', async () => {
|
||||
const { c, h } = await loaded();
|
||||
assert.equal(await c.apply(), true);
|
||||
assert.equal(h.of('POST', `${ITEMS}/one/decision`)[0].body.expected_epoch, EPOCH);
|
||||
});
|
||||
|
||||
test('M3: a quiet re-read under another epoch reads the open item again', async () => {
|
||||
const OTHER = '22222222-2222-4222-8222-222222222222';
|
||||
let epoch = EPOCH;
|
||||
const { c, h } = await loaded({
|
||||
[`GET ${ITEMS}`]: () => response(200, page(QUEUE, { library_epoch: epoch })),
|
||||
[`GET ${ITEMS}/one`]: () => response(200, detailOf(QUEUE[0], { library_epoch: epoch })),
|
||||
});
|
||||
epoch = OTHER;
|
||||
await c.refresh();
|
||||
await settle();
|
||||
assert.equal(h.of('GET', `${ITEMS}/one`).length, 2);
|
||||
assert.equal(c.state.detail.library_epoch, OTHER);
|
||||
});
|
||||
|
||||
test('M4: leaving during the observe releases the capture it then answers', async () => {
|
||||
let answer;
|
||||
const { c, h } = await loaded({ [`POST ${OBSERVE}`]: () => new Promise(resolve => { answer = resolve; }) });
|
||||
const applying = c.apply();
|
||||
await settle();
|
||||
c.dispose();
|
||||
answer(response(200, observed('late')));
|
||||
assert.equal(await applying, false);
|
||||
await settle();
|
||||
assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + 'late']);
|
||||
assert.equal(h.of('POST', `${ITEMS}/one/decision`).length, 0);
|
||||
});
|
||||
|
||||
test('M5: an unanswered decision that landed finishes as recorded, not as decided elsewhere', async () => {
|
||||
let queue = QUEUE;
|
||||
const { c, h } = await loaded({
|
||||
[`GET ${ITEMS}`]: () => response(200, page(queue)),
|
||||
[`POST ${ITEMS}/one/decision`]: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
});
|
||||
await c.apply();
|
||||
assert.equal(c.state.canRetry, true);
|
||||
queue = [QUEUE[1]]; // the server did record it: its change arrives
|
||||
await c.refresh();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'done');
|
||||
assert.equal(c.state.toast, 'Association accepted.');
|
||||
assert.doesNotMatch(c.state.warning, /decided elsewhere/);
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 0, 'the server used the capture');
|
||||
});
|
||||
@@ -4,7 +4,7 @@ const source = import('../../web/js/workspace/state.mjs');
|
||||
|
||||
test('workspace routes resolve only supported deep links', async () => {
|
||||
const { resolveRoute } = await source;
|
||||
for (const route of ['overview', 'images', 'library', 'tags', 'inspect', 'system', 'activity']) {
|
||||
for (const route of ['overview', 'images', 'library', 'tags', 'inspect', 'system', 'activity', 'review']) {
|
||||
assert.equal(resolveRoute(`#/${route}`).id, route);
|
||||
}
|
||||
for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>', '#/activity/abc', `#/activity/${'A'.repeat(32)}`]) {
|
||||
@@ -24,7 +24,7 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy
|
||||
}
|
||||
assert.equal(routeTitle(resolveRoute('#/library/7')), 'Prompt library');
|
||||
// System is native from S2 (#249 System): its undelivered sections hand off per section (D94), not the route.
|
||||
for (const id of ['images', 'library', 'tags', 'inspect', 'overview', 'activity', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
|
||||
for (const id of ['images', 'library', 'tags', 'inspect', 'overview', 'activity', 'review', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
|
||||
});
|
||||
|
||||
test('system resolves its sections; #/overview stays the production hub', async () => {
|
||||
@@ -157,9 +157,10 @@ test('positive library deep links preserve the selected identity without duplica
|
||||
|
||||
test('nav counts reject negative, non-integer and missing totals', async () => {
|
||||
const { summarizeNavCounts } = await source;
|
||||
const counts = summarizeNavCounts({images: {total: 24}, favorites: {total: 3}, library: {total: 0}, tags: {total: 7}});
|
||||
const counts = summarizeNavCounts({images: {total: 24}, favorites: {total: 3}, library: {total: 0}, tags: {total: 7},
|
||||
review: {total: 3}});
|
||||
assert.ok(counts instanceof Map);
|
||||
assert.deepEqual([...counts], [['images', 24], ['favorites', 3], ['library', 0], ['tags', 7]]);
|
||||
assert.deepEqual([...counts], [['images', 24], ['favorites', 3], ['library', 0], ['tags', 7], ['review', 3]]);
|
||||
assert.equal(counts.has('inspect'), false, 'Inspect a file has no count');
|
||||
for (const invalid of [undefined, null, {}, {total: -1}, {total: 1.5}, {total: '3'}, {total: NaN},
|
||||
{total: Infinity}, {total: Number.MAX_SAFE_INTEGER + 1}, {total: null}, [], 'x']) {
|
||||
@@ -169,7 +170,8 @@ test('nav counts reject negative, non-integer and missing totals', async () => {
|
||||
assert.equal(result.get('library'), 2);
|
||||
assert.equal(result.get('tags'), null);
|
||||
}
|
||||
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['favorites', null], ['library', null], ['tags', null]]);
|
||||
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['favorites', null], ['library', null], ['tags', null],
|
||||
['review', null]]);
|
||||
});
|
||||
|
||||
test('nav counts settle independently so a slow images read never delays the others', async () => {
|
||||
@@ -185,14 +187,14 @@ test('nav counts settle independently so a slow images read never delays the oth
|
||||
const settled = [];
|
||||
const done = loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.deepEqual(settled.sort(), [['favorites', 2], ['library', 3], ['tags', null]]);
|
||||
assert.deepEqual(settled.sort(), [['favorites', 2], ['library', 3], ['review', null], ['tags', null]]);
|
||||
releaseImages({success: true, total: 40});
|
||||
const counts = await done;
|
||||
assert.deepEqual(settled.at(-1), ['images', 40]);
|
||||
assert.deepEqual([...counts], [['images', 40], ['favorites', 2], ['library', 3], ['tags', null]]);
|
||||
assert.deepEqual([...counts], [['images', 40], ['favorites', 2], ['library', 3], ['tags', null], ['review', null]]);
|
||||
});
|
||||
|
||||
test('nav counts issue four bounded requests and drop only the failed count', async () => {
|
||||
test('nav counts issue five bounded requests and drop only the failed count', async () => {
|
||||
const { loadNavCounts } = await source;
|
||||
const calls = [];
|
||||
const signal = new AbortController().signal;
|
||||
@@ -200,13 +202,15 @@ test('nav counts issue four bounded requests and drop only the failed count', as
|
||||
calls.push([url, options.signal]);
|
||||
if (url.startsWith('/prompt_manager/library')) throw new Error('Unavailable');
|
||||
if (url.startsWith('/prompt_manager/vocabulary/tags')) return {success: true, total: 5};
|
||||
if (url.startsWith('/prompt_manager/review/items')) return {success: true, total: 2};
|
||||
return {success: true, total: 12};
|
||||
}};
|
||||
const counts = await loadNavCounts(api, signal);
|
||||
assert.deepEqual([...counts], [['images', 12], ['favorites', 12], ['library', null], ['tags', 5]]);
|
||||
assert.deepEqual([...counts], [['images', 12], ['favorites', 12], ['library', null], ['tags', 5], ['review', 2]]);
|
||||
const urls = calls.map(([url]) => new URL(url, 'http://host'));
|
||||
assert.deepEqual(urls.map(url => url.pathname),
|
||||
['/prompt_manager/images/output', '/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
||||
['/prompt_manager/images/output', '/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags',
|
||||
'/prompt_manager/review/items']);
|
||||
for (const url of urls) assert.equal(url.searchParams.get('limit'), '1');
|
||||
assert.equal(urls[0].searchParams.get('mode'), 'workspace');
|
||||
assert.equal(urls[0].searchParams.has('favorites'), false);
|
||||
@@ -288,3 +292,22 @@ test('saved-view links resolve to their id and re-render only when the id change
|
||||
assert.deepEqual(seen,[['saved',3],['saved',4],['images',undefined]]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
test('Needs review is a native SAVED VIEWS route titled Review, with its own count', async () => {
|
||||
const { resolveRoute, isHandoff, routeTitle, routes, loadNavCounts, REVIEW_WRITE_COUNTS, NAV_COUNT_ROUTES } = await source;
|
||||
const route = resolveRoute('#/review');
|
||||
assert.equal(route.id, 'review');
|
||||
assert.equal(route.label, 'Needs review');
|
||||
assert.equal(routeTitle(route), 'Review');
|
||||
assert.equal(isHandoff(route), false);
|
||||
// No href: Overview lists no legacy card for it.
|
||||
assert.equal(routes.find(item => item.id === 'review').href, undefined);
|
||||
assert.ok(NAV_COUNT_ROUTES.includes('review'));
|
||||
assert.deepEqual([...REVIEW_WRITE_COUNTS], ['review']);
|
||||
assert.ok(Object.isFrozen(REVIEW_WRITE_COUNTS));
|
||||
const calls = [];
|
||||
const api = {get: async url => { calls.push(url); return {success: true, total: 4}; }};
|
||||
const counts = await loadNavCounts(api, new AbortController().signal, () => {}, REVIEW_WRITE_COUNTS);
|
||||
assert.deepEqual(calls, ['/prompt_manager/review/items?limit=1']);
|
||||
assert.deepEqual([...counts], [['review', 4]]);
|
||||
});
|
||||
|
||||
+72
-11
@@ -1,12 +1,11 @@
|
||||
"""Needs review decisions and queue reads (#249 E3).
|
||||
|
||||
The queue lists pending items oldest first behind an HMAC cursor bound to the
|
||||
storage epoch and the review index revision; ``total`` is the exact pending
|
||||
count. A decision is explicit: Apply links the chosen sampler's pair only
|
||||
under the ingester's compatible rules and only for the current, re-hashed
|
||||
bytes of the reviewed file, in one transaction with its decision row; Leave
|
||||
unresolved records the decision and links nothing. Responses never carry a
|
||||
server path.
|
||||
storage epoch; ``total`` is the exact pending count. A decision is explicit:
|
||||
Apply links the chosen sampler's pair only under the ingester's compatible
|
||||
rules and only for the current, re-hashed bytes of the reviewed file, in one
|
||||
transaction with its decision row; Leave unresolved records the decision and
|
||||
links nothing. Responses never carry a server path.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -199,12 +198,22 @@ class TestQueueReads(ReviewRoutesTestCase):
|
||||
"unavailable",
|
||||
"candidate_count",
|
||||
"limited",
|
||||
"url",
|
||||
"thumbnail_url",
|
||||
"is_video",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
(item["state"], item["revision"], item["filename"]),
|
||||
("pending", 0, "f0.png"),
|
||||
)
|
||||
# The card's media URL, formatted like the Images listing's (no stat).
|
||||
scope = item["target"]["root_scope"]
|
||||
self.assertEqual(
|
||||
item["url"],
|
||||
f"/prompt_manager/images/serve/f0.png?root=0&root_scope={scope}",
|
||||
)
|
||||
self.assertIs(item["is_video"], False)
|
||||
self.assertEqual(item["candidate_count"], 2)
|
||||
self.assertIs(item["unavailable"], False)
|
||||
self.assertEqual(item["target"]["path"], "f0.png")
|
||||
@@ -242,7 +251,7 @@ class TestQueueReads(ReviewRoutesTestCase):
|
||||
status, data = await self._json("GET", ITEMS_URL + "?cursor=" + "x" * 5000)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
async def test_cursor_invalidated_by_decision_and_restore(self):
|
||||
async def test_cursor_survives_queue_changes_and_is_invalidated_by_restore(self):
|
||||
self._require_routes()
|
||||
for index in range(3):
|
||||
self._record(self._ambiguous(f"f{index}.png", color=(index, 0, 0)))
|
||||
@@ -264,16 +273,43 @@ class TestQueueReads(ReviewRoutesTestCase):
|
||||
status, data = await self._json("GET", f"{ITEMS_URL}?cursor={cursor}")
|
||||
self.assertEqual((status, data["code"]), (409, "review-conflict"), data)
|
||||
self.assertEqual(data["library_epoch"], first["library_epoch"])
|
||||
# A decision moves the review index revision: the old cursor is stale.
|
||||
# The cursor is bound to the storage epoch only (M3): a decision and a
|
||||
# new item (the monitor records one per generation) leave "load more"
|
||||
# working, because the (created_at, id) keyset is stable under both.
|
||||
item, detail, observed = await self._open()
|
||||
self.assertEqual(item["id"], first["items"][0]["id"])
|
||||
status, data = await self._decide(
|
||||
item["id"], {**self._body(detail, observed, "unresolved")}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self._record(self._ambiguous("late.png", color=(9, 9, 9)))
|
||||
rows = self._items()
|
||||
status, data = await self._json("GET", f"{ITEMS_URL}?limit=1&cursor={cursor}")
|
||||
self.assertEqual(status, 409, data)
|
||||
self.assertEqual(data["code"], "review-conflict")
|
||||
self.assertEqual(data["total"], 2)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["total"], 3)
|
||||
self.assertEqual([i["filename"] for i in data["items"]], ["f1.png"])
|
||||
seen = ["f1.png"]
|
||||
while data["next_cursor"]:
|
||||
data = await self._list(f"?limit=1&cursor={data['next_cursor']}")
|
||||
seen.extend(i["filename"] for i in data["items"])
|
||||
self.assertEqual(seen, ["f1.png", "f2.png", "late.png"])
|
||||
self.assertEqual(len(rows), 4)
|
||||
# A decision on an item after the cursor drops it from later pages.
|
||||
second = await self._list("?limit=1")
|
||||
self.assertEqual([i["filename"] for i in second["items"]], ["f1.png"])
|
||||
conn = self.api.db.model.get_connection()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"UPDATE media_review_items SET state='selected', revision=1 "
|
||||
"WHERE id=(SELECT i.id FROM media_review_items i "
|
||||
"WHERE i.spelled_path LIKE ?)",
|
||||
("%f2.png",),
|
||||
)
|
||||
data = await self._list(f"?limit=1&cursor={second['next_cursor']}")
|
||||
self.assertEqual(
|
||||
([i["filename"] for i in data["items"]], data["total"]),
|
||||
(["late.png"], 2),
|
||||
)
|
||||
fresh = (await self._list("?limit=1"))["next_cursor"]
|
||||
conn = self.api.db.model.get_connection()
|
||||
with conn: # a restore replaces the storage epoch
|
||||
@@ -354,10 +390,35 @@ class TestQueueReads(ReviewRoutesTestCase):
|
||||
(item["unavailable"], item["target"], item["filename"]),
|
||||
(True, None, "two.png"),
|
||||
)
|
||||
self.assertEqual((item["url"], item["is_video"]), (None, False))
|
||||
self.assertIsNone(item["thumbnail_url"])
|
||||
detail = await self._detail(item["id"])
|
||||
self.assertIs(detail["unavailable"], True)
|
||||
self._no_paths()
|
||||
|
||||
async def test_queue_cards_use_the_images_listing_thumbnail(self):
|
||||
# M10: a known thumbnail is the card's media, as on the Images page;
|
||||
# without one the card falls back to the original.
|
||||
self._require_routes()
|
||||
from py.api.images import _output_image_entry
|
||||
|
||||
path = self._ambiguous("sub/f0.png")
|
||||
self._record(path)
|
||||
item = (await self._list())["items"][0]
|
||||
self.assertIsNone(item["thumbnail_url"])
|
||||
thumb = self.output_dir / "thumbnails" / "sub" / "f0_thumb.png"
|
||||
thumb.parent.mkdir(parents=True)
|
||||
write_png(thumb, prompt=ambiguous_graph("cat", "dog"), color=(1, 2, 3))
|
||||
item = (await self._list())["items"][0]
|
||||
scope = item["target"]["root_scope"]
|
||||
listed = _output_image_entry(path, self.output_dir, 0, root_scope=scope)
|
||||
self.assertIsNotNone(listed["thumbnail_url"])
|
||||
self.assertEqual(item["thumbnail_url"], listed["thumbnail_url"])
|
||||
self.assertEqual(item["url"], listed["url"])
|
||||
detail = await self._detail(item["id"])
|
||||
self.assertEqual(detail["thumbnail_url"], listed["thumbnail_url"])
|
||||
self._no_paths()
|
||||
|
||||
def test_target_formatting_keeps_the_recorded_spelling_on_windows(self):
|
||||
self._require_routes()
|
||||
spelled = "C:\\Out\\Sub\\Image_0001.PNG"
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Needs review change notices (#249 PR F2).
|
||||
|
||||
``GET /prompt_manager/review/events`` is a server-sent event stream: one
|
||||
``ready`` event when it opens, then one ``change`` event after every
|
||||
committed queue change (an item recorded by the scan or the monitor, a
|
||||
spelling refresh, a decision from any tab) and after a restore. An open
|
||||
Needs review page re-reads its queue on ``change``; nothing polls. A refused
|
||||
decision and a duplicate recording change nothing and send nothing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
try:
|
||||
from tests.test_api_review import ReviewRoutesTestCase
|
||||
from tests.test_review import unlinkable_graph, write_png
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from test_api_review import ReviewRoutesTestCase
|
||||
from test_review import unlinkable_graph, write_png
|
||||
|
||||
try: # guarded so a missing module fails assertions, not test loading
|
||||
from database import review_changes
|
||||
except ImportError:
|
||||
review_changes = None
|
||||
try:
|
||||
from py.api import review_events
|
||||
except ImportError:
|
||||
review_events = None
|
||||
|
||||
EVENTS_URL = "/prompt_manager/review/events"
|
||||
# A safety bound for one read; every wait below ends on an event first.
|
||||
READ_SECONDS = 10
|
||||
|
||||
|
||||
class ReviewEventsTestCase(ReviewRoutesTestCase):
|
||||
def _require_events(self):
|
||||
self.assertIsNotNone(review_changes, "database.review_changes is missing")
|
||||
self.assertIsNotNone(review_events, "py.api.review_events is missing")
|
||||
|
||||
async def _stream(self):
|
||||
resp = await self.client.get(EVENTS_URL)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(resp.headers["Content-Type"], "text/event-stream")
|
||||
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
||||
self.addAsyncCleanup(self._close, resp)
|
||||
self.assertEqual(await self._event(resp), "ready")
|
||||
return resp
|
||||
|
||||
@staticmethod
|
||||
async def _close(resp):
|
||||
resp.close()
|
||||
|
||||
async def _event(self, resp):
|
||||
"""The next event's name (keepalive comments are skipped)."""
|
||||
return (await self._event_with_data(resp))[0]
|
||||
|
||||
async def _event_with_data(self, resp):
|
||||
"""The next event's name and its parsed ``data`` line."""
|
||||
name, data = None, None
|
||||
while True:
|
||||
line = await asyncio.wait_for(resp.content.readline(), READ_SECONDS)
|
||||
self.assertTrue(line, "the stream ended")
|
||||
text = line.decode("utf-8").rstrip("\n")
|
||||
if text.startswith("event: "):
|
||||
name = text[len("event: ") :]
|
||||
elif text.startswith("data: "):
|
||||
data = json.loads(text[len("data: ") :])
|
||||
elif text == "" and name is not None:
|
||||
return name, data
|
||||
|
||||
async def _ready_version(self):
|
||||
resp = await self.client.get(EVENTS_URL)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.addAsyncCleanup(self._close, resp)
|
||||
name, data = await self._event_with_data(resp)
|
||||
self.assertEqual(name, "ready")
|
||||
return data
|
||||
|
||||
def _queueable(self, name="two.png", color=(255, 0, 0)):
|
||||
"""A file the producers queue: ambiguous, and nothing links it."""
|
||||
path = self.output_dir / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_png(path, prompt=unlinkable_graph("cat", "dog"), color=color)
|
||||
return path
|
||||
|
||||
async def _record_in_thread(self, path):
|
||||
"""Record as the monitor does: from a worker thread, not the loop."""
|
||||
self._roots()
|
||||
outcomes = []
|
||||
thread = threading.Thread(
|
||||
target=lambda: outcomes.append(self.api.record_review_file(str(path)))
|
||||
)
|
||||
thread.start()
|
||||
await asyncio.get_running_loop().run_in_executor(None, thread.join)
|
||||
return outcomes[0]
|
||||
|
||||
|
||||
class TestReviewEvents(ReviewEventsTestCase):
|
||||
async def test_monitor_recording_sends_one_change(self):
|
||||
self._require_events()
|
||||
stream = await self._stream()
|
||||
self.assertEqual(await self._record_in_thread(self._queueable()), "recorded")
|
||||
self.assertEqual(await self._event(stream), "change")
|
||||
|
||||
async def test_duplicate_recording_and_refused_decision_send_nothing(self):
|
||||
# A spy on the notice itself: a wrong notice would otherwise pass as
|
||||
# the next real change's event.
|
||||
self._require_events()
|
||||
from unittest.mock import patch
|
||||
|
||||
from database import review as review_db
|
||||
from database import review_decisions
|
||||
|
||||
calls = []
|
||||
original = review_changes.notify_review_change
|
||||
|
||||
def spy(db_path):
|
||||
calls.append(db_path)
|
||||
original(db_path)
|
||||
|
||||
path = self._queueable("two.png")
|
||||
self._record(path)
|
||||
with (
|
||||
patch.object(review_db, "notify_review_change", spy),
|
||||
patch.object(review_decisions, "notify_review_change", spy),
|
||||
):
|
||||
# The same bytes again: nothing changes.
|
||||
self.assertEqual(await self._record_in_thread(path), "duplicate")
|
||||
self.assertEqual(calls, [], "a duplicate recording sent a notice")
|
||||
item, detail, observed = await self._open()
|
||||
# A stale revision: refused with zero writes.
|
||||
body = {
|
||||
**self._body(detail, observed, "unresolved"),
|
||||
"expected_revision": 9,
|
||||
}
|
||||
status, data = await self._decide(item["id"], body)
|
||||
self.assertEqual(status, 409, data)
|
||||
self.assertEqual(calls, [], "a refused decision sent a notice")
|
||||
self._record(self._ambiguous("other.png", color=(0, 0, 9)))
|
||||
self.assertEqual(len(calls), 1, "a real recording sends one notice")
|
||||
self.assertEqual(len(self._items()), 2)
|
||||
|
||||
async def test_a_burst_of_changes_coalesces_into_one_more_event(self):
|
||||
# M9: a busy scan records file after file; an open page hears the
|
||||
# first at once and the rest as one trailing ``change``.
|
||||
self._require_events()
|
||||
from unittest.mock import patch
|
||||
|
||||
path = self.api.db.model.db_path
|
||||
with (
|
||||
patch.object(review_events, "COALESCE_SECONDS", 0.3),
|
||||
patch.object(review_events, "HEARTBEAT_SECONDS", 0.6),
|
||||
):
|
||||
stream = await self._stream()
|
||||
review_changes.notify_review_change(path)
|
||||
self.assertEqual(await self._event(stream), "change")
|
||||
for _ in range(5):
|
||||
review_changes.notify_review_change(path)
|
||||
await asyncio.sleep(0.02)
|
||||
# Every change event up to the next keepalive: exactly one.
|
||||
changes = 0
|
||||
while True:
|
||||
line = await asyncio.wait_for(stream.content.readline(), READ_SECONDS)
|
||||
self.assertTrue(line, "the stream ended")
|
||||
if line == b": keepalive\n":
|
||||
break
|
||||
changes += line == b"event: change\n"
|
||||
self.assertEqual(changes, 1)
|
||||
|
||||
async def test_decision_from_any_tab_sends_a_change(self):
|
||||
self._require_events()
|
||||
self._record(self._ambiguous())
|
||||
stream = await self._stream()
|
||||
item, detail, observed = await self._open()
|
||||
status, data = await self._decide(
|
||||
item["id"], self._body(detail, observed, "unresolved")
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(await self._event(stream), "change")
|
||||
|
||||
async def test_restore_sends_a_change(self):
|
||||
self._require_events()
|
||||
stream = await self._stream()
|
||||
from database.execution_history import notify_history_restore
|
||||
|
||||
notify_history_restore(self.api.db.model.db_path)
|
||||
self.assertEqual(await self._event(stream), "change")
|
||||
|
||||
async def test_scan_recording_sends_a_change(self):
|
||||
self._require_events()
|
||||
self._roots()
|
||||
self._queueable("sub/two.png")
|
||||
stream = await self._stream()
|
||||
job = await self._scan("all")
|
||||
self.assertEqual(job["scope"]["review_items"], 1)
|
||||
self.assertEqual(await self._event(stream), "change")
|
||||
|
||||
async def test_a_linked_file_leaving_the_queue_sends_a_change(self):
|
||||
"""The queue heals (#284 maintainer test): a pending item whose file is
|
||||
linked now leaves the queue, and the open page hears about it."""
|
||||
self._require_events()
|
||||
self._roots()
|
||||
path = self._ambiguous("two.png")
|
||||
self._record(path) # as the pre-fix scan queued fallback-linked files
|
||||
stream = await self._stream()
|
||||
job = await self._scan("new")
|
||||
self.assertEqual(job["scope"]["review_settled"], 1)
|
||||
self.assertEqual(self._items(), [])
|
||||
self.assertEqual(await self._event(stream), "change")
|
||||
|
||||
async def test_ready_carries_the_version_a_list_read_answers(self):
|
||||
# F3 (no double read per visit): the page skips the re-read on
|
||||
# ``ready`` when its first read already answered this version.
|
||||
self._require_events()
|
||||
self._record(self._ambiguous())
|
||||
page = await self._list()
|
||||
self.assertIsInstance(page.get("review_revision"), int, page)
|
||||
version = await self._ready_version()
|
||||
self.assertEqual(
|
||||
version,
|
||||
{
|
||||
"library_epoch": page["library_epoch"],
|
||||
"review_revision": page["review_revision"],
|
||||
},
|
||||
)
|
||||
|
||||
async def test_a_queue_change_moves_the_version_ready_carries(self):
|
||||
self._require_events()
|
||||
before = await self._list()
|
||||
self._record(self._ambiguous())
|
||||
after = await self._list()
|
||||
self.assertIn("review_revision", after)
|
||||
self.assertGreater(after["review_revision"], before["review_revision"])
|
||||
version = await self._ready_version()
|
||||
self.assertEqual(version["review_revision"], after["review_revision"])
|
||||
self.assertNotEqual(version["review_revision"], before["review_revision"])
|
||||
|
||||
async def test_ready_names_no_version_when_it_cannot_be_read(self):
|
||||
# The page then re-reads, as it did before versions existed.
|
||||
self._require_events()
|
||||
|
||||
def broken():
|
||||
raise sqlite3.DatabaseError("locked")
|
||||
|
||||
self.api.db.review_queue_version = broken
|
||||
self.assertEqual(await self._ready_version(), {})
|
||||
|
||||
async def test_a_stream_counts_as_open_once_it_has_sent_ready(self):
|
||||
# The browser fixture waits on this count before it changes the
|
||||
# queue, so a change after it is a ``change`` event and never part of
|
||||
# the version ``ready`` named.
|
||||
self._require_events()
|
||||
reading, release = asyncio.Event(), asyncio.Event()
|
||||
original = self.api._queue_version
|
||||
|
||||
async def held():
|
||||
reading.set()
|
||||
await release.wait()
|
||||
return await original()
|
||||
|
||||
self.api._queue_version = held
|
||||
opening = asyncio.ensure_future(self._ready_version())
|
||||
await asyncio.wait_for(reading.wait(), READ_SECONDS)
|
||||
self.assertEqual(self.api.review_stream_count(), 0)
|
||||
release.set()
|
||||
await asyncio.wait_for(opening, READ_SECONDS)
|
||||
self.assertEqual(self.api.review_stream_count(), 1)
|
||||
|
||||
async def test_change_events_carry_no_data(self):
|
||||
self._require_events()
|
||||
stream = await self._stream()
|
||||
self._record(self._ambiguous())
|
||||
self.assertEqual(await self._event_with_data(stream), ("change", {}))
|
||||
|
||||
async def test_closing_the_stream_unsubscribes(self):
|
||||
self._require_events()
|
||||
path = self.api.db.model.db_path
|
||||
stream = await self._stream()
|
||||
self.assertEqual(review_changes.observer_count(path), 1)
|
||||
self.assertEqual(self.api.review_stream_count(), 1)
|
||||
stream.close()
|
||||
# The server notices the closed socket at its next write: a change.
|
||||
self._record(self._ambiguous())
|
||||
for _ in range(100):
|
||||
if not self.api.review_stream_count():
|
||||
break
|
||||
await asyncio.sleep(0.02)
|
||||
self.assertEqual(self.api.review_stream_count(), 0)
|
||||
self.assertEqual(review_changes.observer_count(path), 0)
|
||||
|
||||
async def test_shutdown_ends_every_stream(self):
|
||||
self._require_events()
|
||||
stream = await self._stream()
|
||||
await self.api.close_review_events(None)
|
||||
line = await asyncio.wait_for(stream.content.read(), READ_SECONDS)
|
||||
self.assertEqual(line, b"")
|
||||
self.assertEqual(self.api.review_stream_count(), 0)
|
||||
# A stream opened after shutdown ends at once.
|
||||
resp = await self.client.get(EVENTS_URL)
|
||||
self.assertEqual(resp.status, 503)
|
||||
resp.close()
|
||||
|
||||
async def test_keepalive_comment_is_skipped_by_clients(self):
|
||||
self._require_events()
|
||||
with self.patch_heartbeat(0.05):
|
||||
stream = await self._stream()
|
||||
line = await asyncio.wait_for(stream.content.readline(), READ_SECONDS)
|
||||
self.assertEqual(line, b": keepalive\n")
|
||||
|
||||
def patch_heartbeat(self, seconds):
|
||||
from unittest.mock import patch
|
||||
|
||||
return patch.object(review_events, "HEARTBEAT_SECONDS", seconds)
|
||||
|
||||
|
||||
class TestReviewChangeRegistry(ReviewEventsTestCase):
|
||||
def test_a_failing_observer_never_fails_a_write(self):
|
||||
self._require_events()
|
||||
path = self.api.db.model.db_path
|
||||
seen = []
|
||||
|
||||
def broken():
|
||||
raise RuntimeError("observer failed")
|
||||
|
||||
unsubscribe_broken = review_changes.subscribe_review_changes(path, broken)
|
||||
unsubscribe = review_changes.subscribe_review_changes(
|
||||
path, lambda: seen.append(1)
|
||||
)
|
||||
try:
|
||||
with self.assertLogs(
|
||||
"prompt_manager.database.review_changes", level="WARNING"
|
||||
):
|
||||
self._record(self._ambiguous())
|
||||
self.assertEqual(seen, [1])
|
||||
finally:
|
||||
unsubscribe_broken()
|
||||
unsubscribe()
|
||||
self.assertEqual(review_changes.observer_count(path), 0)
|
||||
|
||||
def test_notice_comes_after_the_commit(self):
|
||||
self._require_events()
|
||||
path = self.api.db.model.db_path
|
||||
seen = []
|
||||
|
||||
def committed():
|
||||
# A separate connection sees only committed rows.
|
||||
other = sqlite3.connect(path)
|
||||
try:
|
||||
seen.append(
|
||||
other.execute("SELECT COUNT(*) FROM media_review_items").fetchone()[
|
||||
0
|
||||
]
|
||||
)
|
||||
finally:
|
||||
other.close()
|
||||
|
||||
unsubscribe = review_changes.subscribe_review_changes(path, committed)
|
||||
try:
|
||||
self._record(self._ambiguous())
|
||||
finally:
|
||||
unsubscribe()
|
||||
self.assertEqual(seen, [1])
|
||||
+19
-1
@@ -175,7 +175,7 @@
|
||||
},
|
||||
"workspace/activity.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 87,
|
||||
"branches": 88,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
@@ -215,6 +215,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/image-card.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/image-layout.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
@@ -437,6 +443,18 @@
|
||||
"functions": 80,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/review-state.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 84,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/review.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 89,
|
||||
"functions": 93,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/saved-views.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* concept's classes.
|
||||
*
|
||||
* - Header: the page heading names the job; its line is the Activity item's (D54), and its actions are Download report
|
||||
* (the job's JSON report, D59) and, while the job runs, "Cancel scan" (danger).
|
||||
* (the job's JSON report, D59) and, while the job runs, "Cancel scan" (danger). D87: an ended scan that recorded
|
||||
* Needs review items adds the concept's **Review metadata** after Download report.
|
||||
* - Progress (`section.surface`): `<progress>`, the server's "Processing file N of M · about X min left", and the
|
||||
* outcome counters as `dl.stat-row` tiles: Added to Library (green), Already known, Needs review (warning), No usable
|
||||
* metadata, Errors (red).
|
||||
@@ -23,7 +24,7 @@ import { pageHeading } from './heading.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
import { parseJob, jobView, JOB_VIEWS, JOBS_URL, LOST_DETAIL } from './activity-store.mjs';
|
||||
import { jobDetail, CANCEL_FAILED_TOAST } from './activity.mjs';
|
||||
import { jobDetail, CANCEL_FAILED_TOAST, createReviewMetadataButton, hasReviewItems } from './activity.mjs';
|
||||
import { downloadJobReport } from './activity-scan.mjs';
|
||||
import { createMediaViewer } from './media-viewer.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
@@ -115,6 +116,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
const parts = pageHeading(main);
|
||||
const download = createButton('Download report');
|
||||
const cancel = createButton('Cancel scan', 'danger');
|
||||
const review = createReviewMetadataButton(scope); // D87
|
||||
|
||||
const progressPart = section('Progress', 'job-progress-title');
|
||||
const status = element('span', '', 'small');
|
||||
@@ -274,6 +276,8 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
const lost = job.state === 'lost';
|
||||
if (lost && document.activeElement === download) parts.h1.focus();
|
||||
download.disabled = lost;
|
||||
if (hasReviewItems(job)) { if (!review.isConnected) download.after(review); } // D87
|
||||
else review.remove();
|
||||
// Truth rule: a job that cannot be cancelled (a held legacy-page job) shows no Cancel.
|
||||
if (job.state === 'running' && job.cancellable) {
|
||||
if (!cancel.isConnected) parts.actions.append(cancel);
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* - D54: each `.activity-item` is a real job. Its chip names the state (green "Completed", or blue "Running",
|
||||
* "Cancelled", "Failed", "Ended"), its line says when and what, and a running job shows its `<progress>` and
|
||||
* "Cancel job" (no "Simulate completion").
|
||||
* - D87 (plan D64): an ended scan that recorded Needs review items carries the concept's **Review metadata**
|
||||
* (`go-review`, app.js a738) after its line, which opens `#/review`; its chip still names its state (D54).
|
||||
* - D89: an image tagging job's line names its images and, once ended, "N tagged · M errors".
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
@@ -24,6 +26,19 @@ export const ACTIVITY_DESCRIPTION = 'Scans, tagging, downloads and maintenance s
|
||||
export const EMPTY_ACTIVITY = 'No jobs have run since ComfyUI started. Scans and other long operations appear here.';
|
||||
export const LOAD_FAILED = 'Activity could not be read. It is tried again shortly.';
|
||||
export const CANCEL_FAILED_TOAST = 'The job could not be cancelled. Try again.';
|
||||
|
||||
/** Whether an ended job recorded items for Needs review (a metadata scan's `scope.review_items`). */
|
||||
export function hasReviewItems(job) {
|
||||
const count = job?.scope?.review_items;
|
||||
return job?.state !== 'running' && Number.isSafeInteger(count) && count > 0;
|
||||
}
|
||||
|
||||
/** The concept's **Review metadata** (D87): opens Needs review. */
|
||||
export function createReviewMetadataButton(scope) {
|
||||
const button = createButton('Review metadata');
|
||||
scope.listen(button, 'click', () => navigateTo('#/review'));
|
||||
return button;
|
||||
}
|
||||
const CHIPS = Object.freeze({
|
||||
running: ['blue', 'Running'], finished: ['green', 'Completed'], cancelled: ['blue', 'Cancelled'],
|
||||
failed: ['blue', 'Failed'], lost: ['blue', 'Ended'],
|
||||
@@ -129,6 +144,7 @@ function itemFor(job, scope) {
|
||||
const actions = element('div', undefined, 'row-actions');
|
||||
const cancel = createButton('Cancel job');
|
||||
actions.append(cancel);
|
||||
const review = createReviewMetadataButton(scope); // D87
|
||||
item.append(heading, detail, progress, actions);
|
||||
scope.listen(cancel, 'click', async () => {
|
||||
if (cancel.getAttribute('aria-disabled') === 'true') return;
|
||||
@@ -166,6 +182,8 @@ function itemFor(job, scope) {
|
||||
if (actions.contains(document.activeElement)) link.focus();
|
||||
actions.remove();
|
||||
}
|
||||
if (hasReviewItems(next)) { if (!review.isConnected) detail.after(review); } // D87
|
||||
else review.remove();
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The concept media card (docs/concepts/workspace/app.js `mediaCard` a396–399), shared by Images, every
|
||||
* Library-embedded gallery and the Needs review queue: `article.media-card` > `.media-art` (the `.image-open` button
|
||||
* holding the media, then `.image-overlays`) and `.media-info` (title, "W × H · EXT · Seed N", then
|
||||
* `.image-card-actions` with Info and Tag). #249 D7–D11, D19.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { cardTitle, cardSubtitle, imageKey, mediaRatio } from './images-state.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { createTagButton } from './image-tagging.mjs';
|
||||
import { paintStar } from './images-favorites.mjs';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The card's media: a video's first frame when it has no thumbnail, else the thumbnail or the image. */
|
||||
export function cardMedia(image, lazy = false) {
|
||||
const { resolveUrl } = window.ApiClient;
|
||||
const video = image.is_video && !image.thumbnail_url;
|
||||
const node = el(video ? 'video' : 'img');
|
||||
node.src = resolveUrl(image.thumbnail_url || image.url);
|
||||
if (video) { node.muted = true; node.playsInline = true; node.preload = 'metadata'; }
|
||||
else {
|
||||
// The concept card image: alt is the card title, with the listing's intrinsic size when known.
|
||||
node.alt = cardTitle(image); node.decoding = 'async'; if (lazy) node.loading = 'lazy';
|
||||
if ([image.width, image.height].every(value => Number.isFinite(value) && value > 0)) {
|
||||
node.width = image.width; node.height = image.height;
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* One card for `image`. `checked` (a boolean) adds the D8 selection checkbox, `favoriteBusy` paints a pending star
|
||||
* (D7, shown wherever the image reports `favorite`), `media: false` leaves the open button empty for a file that
|
||||
* cannot be served, and `showInfo: false` hides the title and file line (D97).
|
||||
*/
|
||||
export function createImageCard(image, { checked = null, favoriteBusy = false, media = true, showInfo = true } = {}) {
|
||||
const card = el('article', undefined, 'media-card');
|
||||
card.dataset.imageKey = imageKey(image); card.setAttribute('role', 'listitem');
|
||||
card.style.setProperty('--media-ratio', mediaRatio(image));
|
||||
const art = el('div', undefined, 'media-art');
|
||||
const open = createButton('', 'image-open');
|
||||
open.setAttribute('aria-label', `Open ${image.filename}`);
|
||||
if (media) open.append(cardMedia(image, true));
|
||||
// D8: the selection checkbox (Images only: Library-embedded media has no selection bar), then D7: the star,
|
||||
// wherever the listing reports favorites (Images and Library-embedded media).
|
||||
const overlays = el('div', undefined, 'image-overlays');
|
||||
if (typeof checked === 'boolean') {
|
||||
const label = el('label'), box = el('input');
|
||||
box.type = 'checkbox'; box.checked = checked;
|
||||
box.setAttribute('aria-label', `Select ${image.filename}`);
|
||||
label.append(box);
|
||||
overlays.append(label);
|
||||
}
|
||||
if (typeof image.favorite === 'boolean') {
|
||||
const star = createButton('', 'star-button');
|
||||
star.setAttribute('aria-label', `Favorite ${image.filename}`);
|
||||
paintStar(star, image.favorite, favoriteBusy);
|
||||
overlays.append(star);
|
||||
}
|
||||
art.append(open, overlays);
|
||||
const info = el('div', undefined, 'media-info');
|
||||
const text = el('div');
|
||||
const title = el('h3', cardTitle(image)); if (image.path) title.title = image.path;
|
||||
text.append(title, el('p', cardSubtitle(image)));
|
||||
// D97: `showInfo` false (System › Appearance) hides the title and file line; Info and Tag stay.
|
||||
if (!showInfo) text.hidden = true;
|
||||
const actions = el('div', undefined, 'image-card-actions');
|
||||
const inspectButton = createButton('', 'icon-btn');
|
||||
inspectButton.append(createWorkspaceIcon('info'));
|
||||
inspectButton.setAttribute('aria-label', `Inspect ${image.filename}`);
|
||||
actions.append(inspectButton, createTagButton(image));
|
||||
info.append(text, actions);
|
||||
card.append(art, info);
|
||||
return card;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio, initialImagesView } from './images-state.mjs';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, initialImagesView } from './images-state.mjs';
|
||||
import { createImageCard } from './image-card.mjs';
|
||||
import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs';
|
||||
import { mountFileInspector } from './file-inspector.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
@@ -16,7 +17,7 @@ import { openMetadataScan } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imagesFilters, imagesViewState } from './saved-views.mjs';
|
||||
import { displayPreferences } from './preferences.mjs';
|
||||
import { createTagButton, onImageTagsSaved } from './image-tagging.mjs';
|
||||
import { onImageTagsSaved } from './image-tagging.mjs';
|
||||
import { followImageTagSaves, openCardTagging } from './images-tagging.mjs';
|
||||
import { favoriteBusy, paintStar, receiveFavorites, setFavoriteVisitTarget, toggleFavorite } from './images-favorites.mjs';
|
||||
export { onFavoriteChange } from './images-favorites.mjs';
|
||||
@@ -109,7 +110,6 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||||
if (parentScope.signal.aborted) { scope.dispose(); return {dispose}; }
|
||||
const { resolveUrl } = window.ApiClient;
|
||||
const sender = createPromptSender({scope});
|
||||
let detailInspector;
|
||||
// System › Appearance: the display defaults this visit starts from (preferences.mjs); null without gallery-settings.js.
|
||||
@@ -271,20 +271,6 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
}});
|
||||
scope.cleanup(() => mediaViewer.dispose());
|
||||
|
||||
function mediaElement(image, lazy = false) {
|
||||
const video = image.is_video && !image.thumbnail_url;
|
||||
const node = el(video ? 'video' : 'img');
|
||||
node.src = resolveUrl(image.thumbnail_url || image.url);
|
||||
if (video) { node.muted = true; node.playsInline = true; node.preload = 'metadata'; }
|
||||
else {
|
||||
// The concept card image: alt is the card title, with the listing's intrinsic size when known.
|
||||
node.alt = cardTitle(image); node.decoding = 'async'; if (lazy) node.loading = 'lazy';
|
||||
if ([image.width, image.height].every(value => Number.isFinite(value) && value > 0)) {
|
||||
node.width = image.width; node.height = image.height;
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
function releaseVideo(video) {
|
||||
video.pause(); video.removeAttribute('src'); video.load();
|
||||
}
|
||||
@@ -306,46 +292,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (!image) return;
|
||||
mediaViewer.open({items:state.items,selectedKey:imageKey(image),trigger:document.activeElement});
|
||||
}
|
||||
/** The concept media card (app.js mediaCard): the image opens the viewer, Info inspects (D7–D11, D19). */
|
||||
/** The concept media card (image-card.mjs): the image opens the viewer, Info inspects (D7–D11, D19). */
|
||||
function cardFor(image) {
|
||||
const key = imageKey(image);
|
||||
const card = el('article', undefined, 'media-card');
|
||||
card.dataset.imageKey = key; card.setAttribute('role', 'listitem');
|
||||
card.style.setProperty('--media-ratio', mediaRatio(image));
|
||||
const art = el('div', undefined, 'media-art');
|
||||
const open = createButton('', 'image-open');
|
||||
open.setAttribute('aria-label', `Open ${image.filename}`);
|
||||
open.append(mediaElement(image, true));
|
||||
// D8: the selection checkbox (Images only: Library-embedded media has no selection bar), then D7: the star,
|
||||
// wherever the listing reports favorites (Images and Library-embedded media).
|
||||
const overlays = el('div', undefined, 'image-overlays');
|
||||
if (bar) {
|
||||
const label = el('label'), box = el('input');
|
||||
box.type = 'checkbox'; box.checked = bar.isChecked(key);
|
||||
box.setAttribute('aria-label', `Select ${image.filename}`);
|
||||
label.append(box);
|
||||
overlays.append(label);
|
||||
}
|
||||
if (typeof image.favorite === 'boolean') {
|
||||
const star = createButton('', 'star-button');
|
||||
star.setAttribute('aria-label', `Favorite ${image.filename}`);
|
||||
paintStar(star, image.favorite, favoriteBusy(key));
|
||||
overlays.append(star);
|
||||
}
|
||||
art.append(open, overlays);
|
||||
const info = el('div', undefined, 'media-info');
|
||||
const text = el('div');
|
||||
const title = el('h3', cardTitle(image)); title.title = image.path;
|
||||
text.append(title, el('p', cardSubtitle(image)));
|
||||
// D97: "Show image details on cards" off hides the title and file line; Info and Tag stay.
|
||||
if (preferences?.showImageInfo === false) text.hidden = true;
|
||||
const actions = el('div', undefined, 'image-card-actions');
|
||||
const inspectButton = createButton('', 'icon-btn');
|
||||
inspectButton.append(createWorkspaceIcon('info'));
|
||||
inspectButton.setAttribute('aria-label', `Inspect ${image.filename}`);
|
||||
actions.append(inspectButton, createTagButton(image));
|
||||
info.append(text, actions);
|
||||
card.append(art, info);
|
||||
const card = createImageCard(image, {checked:bar ? bar.isChecked(key) : null, favoriteBusy:favoriteBusy(key),
|
||||
// D97: "Show image details on cards" off hides the title and file line; Info and Tag stay.
|
||||
showInfo:preferences?.showImageInfo !== false});
|
||||
markSelected(card, key === selection);
|
||||
return card;
|
||||
}
|
||||
|
||||
+38
-10
@@ -1,7 +1,7 @@
|
||||
import '../api-client.js';
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts,
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS } from './state.mjs';
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS } from './state.mjs';
|
||||
import { mountWorkspaceTheme } from './theme.mjs';
|
||||
import { mountImages, onFavoriteChange } from './images.mjs';
|
||||
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
|
||||
@@ -15,6 +15,7 @@ import { createActivityStore } from './activity-store.mjs';
|
||||
import { mountActivityPage, mountActivityTopbar } from './activity.mjs';
|
||||
import { mountActivityJobView } from './activity-job-view.mjs';
|
||||
import { mountInspect } from './inspect.mjs';
|
||||
import { mountReview } from './review.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { mountSystem } from './system.mjs';
|
||||
|
||||
@@ -85,13 +86,14 @@ function mountWorkspace() {
|
||||
}
|
||||
};
|
||||
// SAVED VIEWS (#249 D16): the concept's saved-nav rows (search icon, name, no count), loaded with the shell and
|
||||
// re-read after every change. /* D49 */ no "PROPOSED" badge; /* D50 */ no "Needs review" row. The first row is
|
||||
// the concept's Favorites (star icon, count), kept across every re-read so its count stays (D51).
|
||||
const [favoritesLink] = navLinks([['favorites', 'star']]);
|
||||
savedNav.replaceChildren(favoritesLink);
|
||||
// re-read after every change. /* D49 */ no "PROPOSED" badge. The first rows are the concept's Favorites (star icon,
|
||||
// count, D51) and Needs review (review icon, the exact pending count, ruling R5), kept across every re-read so
|
||||
// their counts stay.
|
||||
const [favoritesLink, reviewLink] = navLinks([['favorites', 'star'], ['review', 'review']]);
|
||||
savedNav.replaceChildren(favoritesLink, reviewLink);
|
||||
const savedViews = createSavedViews(api, scope.signal);
|
||||
scope.cleanup(savedViews.subscribe(({ views }) => {
|
||||
savedNav.replaceChildren(favoritesLink, ...views.map(view => {
|
||||
savedNav.replaceChildren(favoritesLink, reviewLink, ...views.map(view => {
|
||||
const link = element('a');
|
||||
link.append(createWorkspaceIcon('search'), document.createTextNode(view.name));
|
||||
link.href = savedViewHash(view.id);
|
||||
@@ -113,13 +115,21 @@ function mountWorkspace() {
|
||||
countRequests.set(id, request);
|
||||
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
|
||||
loadNavCounts(api, request.signal, (_, count) => {
|
||||
if (request.signal.aborted) return;
|
||||
const link = sidebar.querySelector(`nav [data-route="${id}"]`);
|
||||
link?.querySelector('.nav-count')?.remove();
|
||||
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
|
||||
if (!request.signal.aborted) showCount(id, count);
|
||||
}, [id]);
|
||||
}
|
||||
}
|
||||
function showCount(id, count) {
|
||||
const link = sidebar.querySelector(`nav [data-route="${id}"]`);
|
||||
link?.querySelector('.nav-count')?.remove();
|
||||
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
|
||||
}
|
||||
/** A count a page has just read itself: it replaces the badge, and an older read in flight no longer lands. */
|
||||
function setCount(id, count) {
|
||||
countRequests.get(id)?.abort();
|
||||
countRequests.delete(id);
|
||||
showCount(id, count);
|
||||
}
|
||||
scope.cleanup(() => { for (const request of countRequests.values()) request.abort(); });
|
||||
// A confirmed Library or vocabulary write cannot change the Images count, so only Library and Tags are re-read.
|
||||
scope.cleanup(onLibraryWrite(() => refreshCounts(LIBRARY_WRITE_COUNTS)));
|
||||
@@ -131,6 +141,17 @@ function mountWorkspace() {
|
||||
favoriteCountTimer = setTimeout(() => refreshCounts(FAVORITE_WRITE_COUNTS), FAVORITE_COUNT_DELAY_MS);
|
||||
}));
|
||||
scope.cleanup(() => clearTimeout(favoriteCountTimer));
|
||||
// A metadata scan that ends may have added items to Needs review: re-read its count once per ended scan.
|
||||
const runningScans = new Set();
|
||||
scope.cleanup(activity.subscribe(() => {
|
||||
let ended = false;
|
||||
for (const job of activity.jobs()) {
|
||||
if (job.kind !== 'scan') continue;
|
||||
if (job.state === 'running') runningScans.add(job.id);
|
||||
else if (runningScans.delete(job.id)) ended = true;
|
||||
}
|
||||
if (ended) refreshCounts(REVIEW_WRITE_COUNTS);
|
||||
}));
|
||||
refreshCounts(NAV_COUNT_ROUTES);
|
||||
let viewScope;
|
||||
let firstRender = true;
|
||||
@@ -261,6 +282,13 @@ function mountWorkspace() {
|
||||
else mountActivityPage(main, { store: activity, scope: view });
|
||||
return;
|
||||
}
|
||||
if (route.id === 'review') {
|
||||
// Needs review (ruling R5): a recorded decision re-reads the row's count, and the open page's total is the
|
||||
// count (F3: a change the page hears of costs no count read).
|
||||
mountReview(main, { api, scope: view, onDecision: () => refreshCounts(REVIEW_WRITE_COUNTS),
|
||||
onTotal: total => setCount('review', total) });
|
||||
return;
|
||||
}
|
||||
if (route.id === 'system') {
|
||||
// The concept's System (app.js renderSystem): its heading description, section nav and Save row.
|
||||
description.textContent = route.description;
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* Needs review state (#249 PR F): the pending queue, the selected item's sampler paths and one explicit decision, over
|
||||
* the review routes (`py/api/review.py`).
|
||||
*
|
||||
* - The queue is read 40 at a time, oldest first; `total` is the exact pending count. The cursor is bound to the
|
||||
* storage epoch only, so Load more keeps working while new items arrive.
|
||||
* - Selecting an item reads its detail: one "Sampler <id> · <positive> / <negative>" option per candidate, unusable
|
||||
* ones disabled, the first workable one chosen, then "Leave unresolved" (rulings R2/R3).
|
||||
* - Apply takes a fresh capture of the file (`observe`), then posts the decision; the server releases a capture it
|
||||
* used. A refused decision releases the capture and keeps the selection with the server's sentence as the warning;
|
||||
* a restored storage or a changed queue reads the queue again. A request that got no answer keeps its capture and
|
||||
* its exact body, and the next Apply of the same choice (`retry()`) resends that body once.
|
||||
* - Leave for later writes nothing. Tagging the image (the shared Tag dialog) never changes the item.
|
||||
* - `refresh()` re-reads the queue quietly when the server says the queue changed (F2: the review change stream:
|
||||
* a scan or the monitor added items, another tab decided one). The open item, its choice and an unanswered decision
|
||||
* stay; when the open item has left the queue the next one opens with a note. Refreshes asked for while one runs
|
||||
* collapse into one more read. Every re-read keeps the loaded depth (F3): it reads page after page until it holds as
|
||||
* many items as were shown, so Load more pages stay and the page does not jump.
|
||||
* - `sync(version)` is the stream's `ready`: it re-reads only when the queue read last is older than the version the
|
||||
* stream names (`{library_epoch, review_revision}`, the pair every list read answers), so a visit reads its queue
|
||||
* once (F3). An unknown version re-reads.
|
||||
* - A change heard while a decision is in flight is owed: it is read once the decision is refused or unanswered (a
|
||||
* recorded one leaves the page). Load more asked for during a re-read waits for it and continues from the queue it
|
||||
* read; a re-read started during Load more takes the click over and reads that page too.
|
||||
* - Apply fences on the epoch of the detail it decides: an observe under another storage releases its capture and
|
||||
* reads the queue and the item again. An unanswered decision that a re-read shows landed finishes as recorded.
|
||||
*/
|
||||
import { noun } from './plural.mjs';
|
||||
|
||||
export const REVIEW_URL = '/prompt_manager/review/items';
|
||||
const OBSERVE_URL = '/prompt_manager/image-annotations/observe';
|
||||
const CAPTURE_URL = '/prompt_manager/image-annotations/capture/';
|
||||
export const PAGE_LIMIT = 40;
|
||||
export const UNRESOLVED = 'unresolved';
|
||||
|
||||
export const COPY = Object.freeze({
|
||||
title: 'Metadata review',
|
||||
description: 'Uncertain evidence stays visible until you decide.',
|
||||
notice: 'Multiple sampler paths were found. The existing image links were preserved. Choose the correct context or leave them unresolved.',
|
||||
// D86 (plan D63, ruling R6): the empty queue's sentence.
|
||||
empty: 'Ambiguous images are added when a metadata scan or a new generation finds more than one sampler path.',
|
||||
panelTitle: 'Resolve the association',
|
||||
leaveUnresolved: 'Leave unresolved',
|
||||
// The states the concept lacks are text only (Global Constraints); D119.
|
||||
loading: 'Loading the review queue…',
|
||||
loadFailed: 'The review queue could not be read.',
|
||||
detailLoading: 'Loading sampler paths…',
|
||||
detailFailed: 'This item could not be read. Select it again to retry.',
|
||||
moreFailed: 'More items could not be loaded. Try again.',
|
||||
limited: 'This file has too many sampler paths to list. You can leave it unresolved.',
|
||||
unavailable: 'This file is not under a current image source, so it cannot be opened. You can leave it unresolved.',
|
||||
lost: 'The decision may not have been saved. Apply again to send the same decision once more.',
|
||||
storageChanged: 'Prompt storage was restored; reload the review queue.',
|
||||
// D119 (F2): the open item left the queue in a re-read, and a failed re-read.
|
||||
decidedElsewhere: name => `${name} was decided elsewhere, so the next image is open.`,
|
||||
refreshFailed: 'The review queue could not be read again. Reload the page to see the latest images.',
|
||||
failed: 'The decision could not be saved. Try again.',
|
||||
applied: 'Association accepted.',
|
||||
leftUnresolved: 'Left unresolved.',
|
||||
});
|
||||
|
||||
// Answers after which the queue itself is out of date: read it again.
|
||||
const RELOAD = new Set(['storage-changed', 'review-conflict', 'review-missing']);
|
||||
|
||||
/** The notice heading: "No images need a decision", "1 image needs a decision", "3 images need a decision". */
|
||||
export function noticeTitle(count) {
|
||||
if (!count) return 'No images need a decision';
|
||||
return `${count.toLocaleString('en-US')} ${noun(count, 'image')} ${count === 1 ? 'needs' : 'need'} a decision`;
|
||||
}
|
||||
|
||||
/** One Sampler context option's text. */
|
||||
export function optionLabel(candidate) {
|
||||
return `Sampler ${candidate.sampler_id} · ${candidate.label}`;
|
||||
}
|
||||
|
||||
const workable = candidate => candidate?.state === 'resolved' && typeof candidate.positive?.text === 'string' &&
|
||||
typeof candidate.negative?.text === 'string';
|
||||
|
||||
/** The Sampler context options of a detail: its candidates (unusable ones disabled), then Leave unresolved. */
|
||||
export function contextOptions(detail) {
|
||||
const usable = detail.target !== null && !detail.unavailable;
|
||||
const options = (Array.isArray(detail.candidates) ? detail.candidates : []).map(candidate => Object.freeze({
|
||||
value: candidate.hash, label: optionLabel(candidate), disabled: !usable || !workable(candidate), candidate,
|
||||
}));
|
||||
options.push(Object.freeze({ value: UNRESOLVED, label: COPY.leaveUnresolved, disabled: false, candidate: null }));
|
||||
return Object.freeze(options);
|
||||
}
|
||||
|
||||
/** The prompt blocks of the chosen option: its two role texts, or the ambiguous status for Leave unresolved. */
|
||||
function promptsOf(option) {
|
||||
if (!option?.candidate) return { positive: { status: 'ambiguous' }, negative: { status: 'ambiguous' } };
|
||||
const { positive, negative } = option.candidate;
|
||||
return { positive: { status: 'resolved', text: positive.text }, negative: { status: 'resolved', text: negative.text } };
|
||||
}
|
||||
|
||||
function warningOf(detail) {
|
||||
if (detail.unavailable || detail.target === null) return COPY.unavailable;
|
||||
if (detail.limited) return COPY.limited;
|
||||
return '';
|
||||
}
|
||||
|
||||
const isAbort = error => error?.name === 'AbortError';
|
||||
// No answer at all (the network, or a body that was not the server's JSON): the decision may have been saved.
|
||||
const isLost = error => !isAbort(error) && !error?.code && !(error?.status >= 400 && error?.status < 500);
|
||||
|
||||
const initial = () => ({
|
||||
status: 'idle', epoch: null, total: 0, items: Object.freeze([]), nextCursor: null, more: 'idle',
|
||||
selectedId: null, detail: null, detailStatus: 'idle', options: Object.freeze([]), choice: null, prompts: null,
|
||||
phase: 'idle', warning: '', canRetry: false, toast: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {{api: {get: Function, post: Function, del: Function}}} options
|
||||
* @returns the controller: `state`, `subscribe(listener)`, `load()`, `refresh()`, `loadMore()`, `select(id)`, `choose(value)`,
|
||||
* `sync(version)`, `apply()`, `retry()`, `leave()`, `dispose()`.
|
||||
*/
|
||||
export function createReviewController({ api }) {
|
||||
let state = Object.freeze(initial());
|
||||
const listeners = new Set();
|
||||
const lifetime = new AbortController();
|
||||
let listRead = null, detailRead = null, pending = null, disposed = false;
|
||||
let refreshing = null, refreshAgain = false;
|
||||
// A change heard while a decision was in flight (I1); read when the phase is idle again.
|
||||
let owedRefresh = false;
|
||||
// Load more's own read, the one waiting for a re-read, and a click a re-read took over (I2).
|
||||
let moreRead = null, moreTask = null, moreOwed = false, listing = null;
|
||||
// What the first read owes once it answers: nothing, a re-read ('always'), or a re-read unless it is this version.
|
||||
let afterLoad = null;
|
||||
// The version of the queue read last: {epoch, revision}.
|
||||
let shown = null;
|
||||
|
||||
function set(update) {
|
||||
if (disposed) return;
|
||||
state = Object.freeze({ ...state, ...update });
|
||||
for (const listener of [...listeners]) listener(state);
|
||||
}
|
||||
const signalOf = controller => AbortSignal.any ? AbortSignal.any([lifetime.signal, controller.signal]) : controller.signal;
|
||||
|
||||
function release(token) {
|
||||
if (!token) return;
|
||||
api.del(`${CAPTURE_URL}${encodeURIComponent(token)}`, { keepalive: true }).catch(() => {});
|
||||
}
|
||||
function dropPending() {
|
||||
release(pending?.body.capture_token);
|
||||
pending = null;
|
||||
}
|
||||
|
||||
const isVersion = version => typeof version?.library_epoch === 'string' && Number.isSafeInteger(version?.review_revision);
|
||||
const current = version => isVersion(version) && shown !== null && shown.epoch === version.library_epoch &&
|
||||
shown.revision >= version.review_revision;
|
||||
|
||||
/** One list page; `cursor` continues after the last item already read. */
|
||||
function readPage(read, cursor) {
|
||||
const query = new URLSearchParams({ limit: String(PAGE_LIMIT) });
|
||||
if (cursor) query.set('cursor', cursor);
|
||||
return api.get(`${REVIEW_URL}?${query}`, { signal: signalOf(read), cache: 'no-store' });
|
||||
}
|
||||
|
||||
/**
|
||||
* The queue down to `depth` items: the first page, then the next ones while fewer than `depth` are held. A restore
|
||||
* met after the first page (its cursor names the old storage) reads once more from the first page.
|
||||
*/
|
||||
async function readQueue(read, depth) {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
const first = await readPage(read, null);
|
||||
const items = [...(first.items ?? [])];
|
||||
let { total } = first;
|
||||
let next = first.next_cursor ?? null;
|
||||
try {
|
||||
while (next && items.length < depth) {
|
||||
const page = await readPage(read, next);
|
||||
const known = new Set(items.map(entry => entry.id));
|
||||
items.push(...(page.items ?? []).filter(entry => !known.has(entry.id)));
|
||||
total = page.total;
|
||||
next = page.next_cursor ?? null;
|
||||
}
|
||||
} catch (error) {
|
||||
if (attempt === 0 && RELOAD.has(error?.code)) continue;
|
||||
throw error;
|
||||
}
|
||||
return { first, items, total, next };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the queue as deep as it is shown; `stale` re-reads the selected item's detail too (its revision or epoch
|
||||
* moved). `quiet` (a refresh) keeps the page as it is while reading and after a failed read.
|
||||
*/
|
||||
function load(options) {
|
||||
const run = listing = loadQueue(options);
|
||||
run.finally(() => { if (listing === run) listing = null; });
|
||||
return run;
|
||||
}
|
||||
|
||||
async function loadQueue({ keepWarning = false, stale = false, quiet = false } = {}) {
|
||||
listRead?.abort();
|
||||
if (moreRead) { moreRead.abort(); moreRead = null; moreOwed = true; }
|
||||
const read = listRead = new AbortController();
|
||||
if (!quiet) set({ status: state.items.length ? state.status : 'loading', ...(keepWarning ? {} : { warning: '' }) });
|
||||
let readOk = false;
|
||||
try {
|
||||
// One page, or as many as Load more had shown (and one page more for a click this read took over).
|
||||
const depth = state.items.length + (moreOwed ? PAGE_LIMIT : 0);
|
||||
const queue = await readQueue(read, depth > PAGE_LIMIT ? depth : 0);
|
||||
if (read !== listRead || disposed) return;
|
||||
moreOwed = false;
|
||||
// M3: another storage (a restore) makes the open item's detail stale too.
|
||||
if (state.epoch !== null && queue.first.library_epoch !== state.epoch) stale = true;
|
||||
const items = Object.freeze(queue.items.map(entry => Object.freeze({ ...entry })));
|
||||
const left = state.items.find(entry => entry.id === state.selectedId);
|
||||
shown = Number.isSafeInteger(queue.first.review_revision)
|
||||
? { epoch: queue.first.library_epoch, revision: queue.first.review_revision } : null;
|
||||
set({ status: 'ready', epoch: queue.first.library_epoch, total: queue.total, items, nextCursor: queue.next,
|
||||
more: 'idle' });
|
||||
readOk = true;
|
||||
const keep = items.find(entry => entry.id === state.selectedId);
|
||||
// A re-read keeps the open item as it is (even a failed read of it, which has its own Retry).
|
||||
if (keep && !stale && (quiet || state.detail)) return;
|
||||
if (quiet && left && !keep && pending?.id === left.id) {
|
||||
// M5: the decision that got no answer was recorded (the server used its capture).
|
||||
const choice = pending.body.choice;
|
||||
pending = null;
|
||||
set({ phase: 'done', warning: '', canRetry: false,
|
||||
toast: choice === UNRESOLVED ? COPY.leftUnresolved : COPY.applied });
|
||||
} else if (quiet && left && !keep && items.length) {
|
||||
set({ warning: COPY.decidedElsewhere(left.filename) });
|
||||
await select(items[0].id, { keepWarning: true });
|
||||
} else if (items.length) await select((keep ?? items[0]).id, { keepWarning });
|
||||
else set({ selectedId: null, detail: null, detailStatus: 'idle', options: Object.freeze([]), choice: null, prompts: null });
|
||||
} catch (error) {
|
||||
if (read !== listRead || disposed || isAbort(error)) return;
|
||||
// M7: a click this read took over can be made again.
|
||||
moreOwed = false;
|
||||
if (quiet && state.status === 'ready') set({ warning: COPY.refreshFailed, more: 'idle' });
|
||||
else set({ status: 'error', more: 'idle' });
|
||||
} finally {
|
||||
// A change heard while the first read ran: that read may predate it, so read once more (unless the
|
||||
// stream's ready named a version this read already holds).
|
||||
const owed = afterLoad;
|
||||
if (readOk && owed && !disposed) {
|
||||
afterLoad = null;
|
||||
if (owed === 'always' || !current(owed)) refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The stream's `ready`: re-read only when the queue shown is older than `version`. */
|
||||
function sync(version) {
|
||||
if (disposed) return Promise.resolve();
|
||||
if (state.status === 'loading') {
|
||||
// A second version, or a change, while the first read runs: no longer one version to compare.
|
||||
afterLoad = afterLoad === null && isVersion(version) ? version : 'always';
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (state.status === 'ready' && current(version)) return Promise.resolve();
|
||||
return refresh();
|
||||
}
|
||||
|
||||
/** Re-read the queue after a change elsewhere; calls made while one runs collapse into one more read. */
|
||||
function refresh() {
|
||||
if (disposed) return Promise.resolve();
|
||||
// Before the queue has been read there is nothing to keep: the first read (or Retry) reads the latest.
|
||||
if (state.status !== 'ready') {
|
||||
if (state.status === 'loading') afterLoad = 'always';
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (refreshing) {
|
||||
refreshAgain = true;
|
||||
return refreshing;
|
||||
}
|
||||
refreshing = (async () => {
|
||||
do {
|
||||
refreshAgain = false;
|
||||
// A decision in flight finishes first: the page leaves when it is recorded, and a refused or
|
||||
// unanswered one reads the owed change (I1).
|
||||
if (state.phase === 'applying') { owedRefresh = true; return; }
|
||||
await load({ keepWarning: true, quiet: true });
|
||||
} while (refreshAgain && !disposed);
|
||||
})().finally(() => { refreshing = null; });
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (moreTask) return moreTask;
|
||||
if (!state.nextCursor || state.more === 'loading') return Promise.resolve();
|
||||
set({ more: 'loading' });
|
||||
moreTask = readMore().finally(() => { moreTask = null; });
|
||||
return moreTask;
|
||||
}
|
||||
|
||||
async function readMore() {
|
||||
// I2: a re-read in flight finishes first; the next page continues after the queue it read.
|
||||
while ((refreshing || listing) && !disposed) await (refreshing ?? listing);
|
||||
if (disposed || !state.nextCursor || state.status !== 'ready') {
|
||||
if (state.more === 'loading') set({ more: 'idle' });
|
||||
return;
|
||||
}
|
||||
if (state.more !== 'loading') set({ more: 'loading' });
|
||||
const read = moreRead = new AbortController();
|
||||
try {
|
||||
const query = new URLSearchParams({ limit: String(PAGE_LIMIT), cursor: state.nextCursor });
|
||||
const page = await api.get(`${REVIEW_URL}?${query}`, { signal: signalOf(read), cache: 'no-store' });
|
||||
if (read !== moreRead || disposed) return;
|
||||
moreRead = null;
|
||||
const known = new Set(state.items.map(entry => entry.id));
|
||||
const added = (page.items ?? []).filter(entry => !known.has(entry.id)).map(entry => Object.freeze({ ...entry }));
|
||||
set({ items: Object.freeze([...state.items, ...added]), total: page.total, nextCursor: page.next_cursor ?? null,
|
||||
more: 'idle' });
|
||||
} catch (error) {
|
||||
if (read !== moreRead || disposed || isAbort(error)) return;
|
||||
moreRead = null;
|
||||
if (RELOAD.has(error?.code)) { set({ more: 'idle' }); await load({ stale: true }); return; }
|
||||
set({ more: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function select(id, { keepWarning = false } = {}) {
|
||||
if (!state.items.some(entry => entry.id === id) || state.phase === 'applying') return;
|
||||
detailRead?.abort();
|
||||
dropPending();
|
||||
const read = detailRead = new AbortController();
|
||||
set({ selectedId: id, detail: null, detailStatus: 'loading', options: Object.freeze([]), choice: null, prompts: null,
|
||||
canRetry: false, ...(keepWarning ? {} : { warning: '' }) });
|
||||
try {
|
||||
const detail = await api.get(`${REVIEW_URL}/${encodeURIComponent(id)}`, { signal: signalOf(read), cache: 'no-store' });
|
||||
if (read !== detailRead || disposed) return;
|
||||
const options = contextOptions(detail);
|
||||
const chosen = options.find(option => !option.disabled);
|
||||
set({ detail: Object.freeze(detail), detailStatus: 'ready', options, choice: chosen.value, prompts: promptsOf(chosen),
|
||||
warning: (keepWarning && state.warning) || warningOf(detail) });
|
||||
} catch (error) {
|
||||
if (read !== detailRead || disposed || isAbort(error)) return;
|
||||
if (RELOAD.has(error?.code)) { set({ warning: error.message }); await load({ keepWarning: true, stale: true }); return; }
|
||||
set({ detailStatus: 'error', warning: COPY.detailFailed });
|
||||
}
|
||||
}
|
||||
|
||||
function choose(value) {
|
||||
const option = state.options.find(entry => entry.value === value);
|
||||
if (!option || option.disabled || state.phase === 'applying') return;
|
||||
// Another choice is another decision: an unanswered one is no longer resent.
|
||||
if (pending && pending.body.choice !== value) { dropPending(); set({ canRetry: false }); }
|
||||
set({ choice: value, prompts: promptsOf(option) });
|
||||
}
|
||||
|
||||
/** The phase is idle again after a refused or unanswered decision: read a change heard meanwhile (I1). */
|
||||
function idleAgain(reloading) {
|
||||
const owed = owedRefresh;
|
||||
owedRefresh = false;
|
||||
if (owed && !reloading && !disposed) refresh();
|
||||
}
|
||||
|
||||
/** Send `body`; true once the decision is recorded. */
|
||||
async function send(body) {
|
||||
const id = state.selectedId;
|
||||
const choice = body.choice;
|
||||
set({ phase: 'applying', canRetry: false });
|
||||
try {
|
||||
const result = await api.post(`${REVIEW_URL}/${encodeURIComponent(id)}/decision`, body, { signal: lifetime.signal });
|
||||
pending = null;
|
||||
set({ phase: 'done', total: result.total, warning: '',
|
||||
toast: choice === UNRESOLVED ? COPY.leftUnresolved : COPY.applied });
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Leaving the page mid-request: the server may never see it, so the capture is released here (a release
|
||||
// of a capture the server already used is a harmless 404).
|
||||
if (disposed || isAbort(error)) { release(body.capture_token); return false; }
|
||||
if (isLost(error)) {
|
||||
pending = { id, body };
|
||||
set({ phase: 'idle', warning: COPY.lost, canRetry: true });
|
||||
idleAgain(false);
|
||||
return false;
|
||||
}
|
||||
pending = null;
|
||||
release(body.capture_token);
|
||||
set({ phase: 'idle', warning: error?.message || COPY.failed });
|
||||
idleAgain(RELOAD.has(error?.code));
|
||||
if (RELOAD.has(error?.code)) await load({ keepWarning: true, stale: true });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
const { detail, choice } = state;
|
||||
if (state.phase !== 'idle' || state.detailStatus !== 'ready' || !detail || !choice) return false;
|
||||
// After an unanswered request, Apply resends that same decision once (its capture and body).
|
||||
if (pending?.id === state.selectedId && pending.body.choice === choice) return retry();
|
||||
dropPending();
|
||||
set({ phase: 'applying', warning: '', canRetry: false });
|
||||
let capture = null;
|
||||
if (detail.target) {
|
||||
let observed;
|
||||
try {
|
||||
// M4: not aborted on leave, so the capture it answers can be released.
|
||||
observed = await api.post(OBSERVE_URL, { target: detail.target });
|
||||
} catch (error) {
|
||||
if (disposed || isAbort(error)) return false;
|
||||
set({ phase: 'idle', warning: error?.message || COPY.failed });
|
||||
idleAgain(RELOAD.has(error?.code));
|
||||
if (RELOAD.has(error?.code)) await load({ keepWarning: true, stale: true });
|
||||
return false;
|
||||
}
|
||||
capture = observed.capture;
|
||||
if (disposed) { release(capture?.token); return false; }
|
||||
// M3: the file was observed under another storage than the detail was read from.
|
||||
if (observed.library_epoch !== detail.library_epoch) {
|
||||
release(capture?.token);
|
||||
set({ phase: 'idle', warning: COPY.storageChanged });
|
||||
idleAgain(true);
|
||||
await load({ keepWarning: true, stale: true });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return send({
|
||||
expected_epoch: detail.library_epoch, expected_revision: detail.revision, capture_token: capture?.token ?? null,
|
||||
sha256: capture?.sha256 ?? detail.sha256, dependency_fingerprint: detail.dependency_fingerprint, choice,
|
||||
});
|
||||
}
|
||||
|
||||
/** Resend the unanswered decision once, with its own capture; false when there is nothing to resend. */
|
||||
async function retry() {
|
||||
if (!pending || state.phase !== 'idle' || pending.id !== state.selectedId) return false;
|
||||
const { body } = pending;
|
||||
pending = null;
|
||||
return send(body);
|
||||
}
|
||||
|
||||
function leave() {
|
||||
if (state.phase === 'applying') return;
|
||||
dropPending();
|
||||
set({ phase: 'done', toast: null, canRetry: false });
|
||||
}
|
||||
|
||||
return {
|
||||
get state() { return state; },
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
load: () => load(), refresh, sync, loadMore, select: id => select(id), choose, apply, retry, leave,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
dropPending();
|
||||
disposed = true;
|
||||
lifetime.abort();
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Needs review (#249 PR F): the concept's Metadata review page (docs/concepts/workspace/app.js `renderReview`
|
||||
* a749–760; style.css s407–415, s796–814 and their breakpoints) over the review controller (review-state.mjs).
|
||||
*
|
||||
* - Heading "Metadata review", its line, and **Open activity** (`#/activity`).
|
||||
* - `.notice`: "N images need a decision" and the concept's sentence; with an empty queue, D86 (plan D63, ruling R6)
|
||||
* "No images need a decision" and the scan/new-generation sentence, and nothing else.
|
||||
* - `.gallery-layout`: the queue's concept media cards (image-card.mjs; the image opens the shared viewer, Info selects
|
||||
* the item, Tag opens the shared Tag image dialog with the review scope note), then `section.surface` "Resolve the
|
||||
* association": filename, Tag image, Sampler context, the chosen path's prompt blocks and the actions Leave for later
|
||||
* and Apply association. Both return to Images (ruling R7).
|
||||
* - States the concept lacks (loading, a failed read, a refused or unanswered decision, a limited or unavailable file)
|
||||
* are text: `p.warning-note` in the panel, or the page's status line (D119).
|
||||
* - The open page re-reads its queue on each `change` of the review change stream (`/prompt_manager/review/events`,
|
||||
* F2): items a scan or the monitor added, and decisions made in other tabs. Each `ready` (the first one, and one
|
||||
* after the browser reconnects a dropped stream) names the queue's version, and the page re-reads only when the
|
||||
* queue it shows is older (F3: one read per visit). A re-read keeps the depth Load more had reached. Nothing polls.
|
||||
* - The stream lives only while the tab is visible: a hidden tab closes it and a visible one opens it again, so
|
||||
* background tabs hold no connection (I4); the reopened stream's `ready` reads only when the queue changed meanwhile.
|
||||
* A stream the browser gave up on (a 503 or a proxy error on its reconnect) is opened again after 3 s, doubling to
|
||||
* 30 s; while the stream is down the page says so in a quiet status line (D120, I3).
|
||||
* - A re-read refreshes each card whose media, target or availability changed (M6). Cards show the Images listing's
|
||||
* thumbnail when the server knows one (M10).
|
||||
* - The SAVED VIEWS row's count follows the total the page shows (`onTotal`), so a change costs no count read.
|
||||
* - D119: the queue cards carry no selection checkbox and no star (this page has no selection bar, and the queue
|
||||
* carries no favorite state); more than 40 items add the Images page's `.infinite-continuation` Load more after the
|
||||
* layout; the toasts "Association accepted." and "Left unresolved." stand in for the concept's prototype toast.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { createReviewController, COPY, noticeTitle } from './review-state.mjs';
|
||||
import { createImageCard } from './image-card.mjs';
|
||||
import { isTagButton, openImageTagging } from './image-tagging.mjs';
|
||||
import { mountPromptBlocks } from './prompt-blocks.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { createMediaViewer } from './media-viewer.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { field } from './workspace-dialogs.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
import { navigateTo } from './state.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imageKey } from './images-state.mjs';
|
||||
|
||||
export const REVIEW_EVENTS_URL = '/prompt_manager/review/events';
|
||||
// I3: reopening a stream the browser closed for good waits this long, doubling up to the maximum.
|
||||
export const REOPEN_FIRST_MS = 3000;
|
||||
export const REOPEN_MAX_MS = 30000;
|
||||
// D120: the page's line while the change stream is down (the concept has no such state).
|
||||
export const NOT_LIVE = 'Live updates are paused: changes made elsewhere appear once the connection returns.';
|
||||
const openEvents = url => new EventSource(window.ApiClient.resolveUrl(url));
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
function setText(node, text) {
|
||||
if (node.textContent !== text) node.textContent = text;
|
||||
}
|
||||
function setDisabled(button, disabled) {
|
||||
if (disabled) button.setAttribute('aria-disabled', 'true');
|
||||
else button.removeAttribute('aria-disabled');
|
||||
}
|
||||
const isDisabled = button => button.getAttribute('aria-disabled') === 'true';
|
||||
|
||||
/** A queue item as the card, the viewer and the Tag dialog read a listed image. */
|
||||
export function imageOf(item) {
|
||||
const target = item.target ?? {};
|
||||
return { filename: item.filename, root_index: target.root_index, root_scope: target.root_scope, path: target.path,
|
||||
url: item.url, thumbnail_url: item.thumbnail_url ?? null, is_video: item.is_video === true,
|
||||
media_type: item.is_video ? 'video' : 'image' };
|
||||
}
|
||||
|
||||
/** True when a re-read changed what a card shows or can open (M6). */
|
||||
const cardChanged = (before, after) => before.url !== after.url || before.thumbnail_url !== after.thumbnail_url ||
|
||||
before.unavailable !== after.unavailable || JSON.stringify(before.target) !== JSON.stringify(after.target);
|
||||
|
||||
/** The queue version a `ready` event names, or null (the page then re-reads). */
|
||||
export function readyVersion(event) {
|
||||
try {
|
||||
const version = JSON.parse(event?.data ?? 'null');
|
||||
return version && typeof version === 'object' ? version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the page under the shell's heading. `onDecision()` runs after a recorded decision and `onTotal(total)` each
|
||||
* time the pending total the page shows changes (the shell's Needs review count); `controller` and `connect` (the
|
||||
* change stream's EventSource) are injectable for tests.
|
||||
*/
|
||||
export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
|
||||
connect = openEvents, controller = createReviewController({ api }) }) {
|
||||
scope.cleanup(() => controller.dispose());
|
||||
const heading = pageHeading(main);
|
||||
heading.h1.textContent = COPY.title;
|
||||
heading.description.textContent = COPY.description;
|
||||
const openActivity = createButton('Open activity');
|
||||
heading.actions.append(openActivity);
|
||||
scope.listen(openActivity, 'click', () => navigateTo('#/activity'));
|
||||
|
||||
const status = el('p', COPY.loading, 'empty-state');
|
||||
status.setAttribute('role', 'status');
|
||||
const retryLoad = createButton('Retry');
|
||||
const notice = el('div', undefined, 'notice');
|
||||
const noticeCopy = el('div');
|
||||
const noticeTitleNode = el('h2');
|
||||
const noticeText = el('p');
|
||||
noticeCopy.append(noticeTitleNode, noticeText);
|
||||
notice.append(noticeCopy);
|
||||
|
||||
const layout = el('div', undefined, 'gallery-layout');
|
||||
const grid = el('div', undefined, 'image-grid review-queue');
|
||||
grid.setAttribute('role', 'list');
|
||||
grid.setAttribute('aria-label', 'Review queue');
|
||||
const panel = el('section', undefined, 'surface review-panel');
|
||||
const panelTitle = el('h2', COPY.panelTitle);
|
||||
panelTitle.id = 'review-panel-title';
|
||||
panel.setAttribute('aria-labelledby', panelTitle.id);
|
||||
const filename = el('p', '', 'small');
|
||||
const tagRow = el('div', undefined, 'row-actions spaced-15');
|
||||
const tagButton = createButton('');
|
||||
tagButton.append(createWorkspaceIcon('tagImage'), ' Tag image');
|
||||
tagRow.append(tagButton);
|
||||
const select = el('select');
|
||||
const contextField = field('Sampler context', select, 'review-context');
|
||||
const warning = el('p', '', 'warning-note'); // D119: the states the concept lacks
|
||||
warning.setAttribute('role', 'status');
|
||||
const blocks = el('div');
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
const later = createButton('Leave for later');
|
||||
const apply = createButton('Apply association', 'ca-primary');
|
||||
actions.append(later, apply);
|
||||
panel.append(panelTitle, filename, tagRow, contextField, warning, blocks, actions);
|
||||
layout.append(grid, panel);
|
||||
|
||||
const live = el('p', NOT_LIVE, 'small review-live'); // D120: the change stream is down
|
||||
live.setAttribute('role', 'status');
|
||||
live.hidden = true;
|
||||
heading.description.after(live);
|
||||
|
||||
const continuation = el('div', undefined, 'infinite-continuation'); // D119: Load more past 40 items
|
||||
const moreText = el('p', '', 'small');
|
||||
const more = createButton('Load more images');
|
||||
continuation.append(moreText, more);
|
||||
|
||||
const sender = createPromptSender({ scope });
|
||||
const prompts = mountPromptBlocks(blocks, { prompts: null, sender, parentScope: scope, includePairAction: false,
|
||||
onRetry: () => { if (controller.state.selectedId) controller.select(controller.state.selectedId); } });
|
||||
const inspection = createInspectionController({ api });
|
||||
scope.cleanup(() => inspection.dispose());
|
||||
const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: inspection,
|
||||
sender, onSelect: image => inspection.select(image) });
|
||||
|
||||
const cards = new Map();
|
||||
let shownItems = null, shownSelected, shownOptions = null, shownPrompts, shownDetail;
|
||||
|
||||
function createCard(item) {
|
||||
// D119: no selection checkbox and no star on a queue card.
|
||||
const card = createImageCard(imageOf(item), { media: Boolean(item.url) });
|
||||
card.dataset.reviewId = item.id;
|
||||
card.item = item;
|
||||
if (!item.url) setDisabled(card.querySelector('.image-open'), true);
|
||||
if (!item.target) setDisabled(card.querySelector('.image-card-actions button:last-child'), true);
|
||||
cards.set(item.id, card);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderCards(state) {
|
||||
if (state.items !== shownItems) {
|
||||
shownItems = state.items;
|
||||
const ids = new Set(state.items.map(item => item.id));
|
||||
for (const [id, card] of cards) if (!ids.has(id)) { card.remove(); cards.delete(id); }
|
||||
state.items.forEach((item, index) => {
|
||||
let card = cards.get(item.id);
|
||||
if (card && cardChanged(card.item, item)) {
|
||||
// M6: a root came or went: the card's media, Open and Tag follow the re-read.
|
||||
const fresh = createCard(item);
|
||||
card.replaceWith(fresh);
|
||||
card = fresh;
|
||||
} else if (card) card.item = item;
|
||||
if (!card) card = createCard(item);
|
||||
if (grid.children[index] !== card) grid.insertBefore(card, grid.children[index] ?? null);
|
||||
});
|
||||
shownSelected = undefined;
|
||||
}
|
||||
if (state.selectedId !== shownSelected) {
|
||||
shownSelected = state.selectedId;
|
||||
for (const [id, card] of cards) card.classList.toggle('selected', id === state.selectedId);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPanel(state) {
|
||||
const item = state.items.find(entry => entry.id === state.selectedId);
|
||||
setText(filename, item?.filename ?? '');
|
||||
setDisabled(tagButton, !item?.target || state.phase === 'applying');
|
||||
if (state.options !== shownOptions) {
|
||||
shownOptions = state.options;
|
||||
select.replaceChildren(...state.options.map(option => {
|
||||
const node = el('option', option.label);
|
||||
node.value = option.value;
|
||||
node.disabled = option.disabled;
|
||||
return node;
|
||||
}));
|
||||
}
|
||||
if (state.choice !== null && select.value !== state.choice) select.value = state.choice;
|
||||
select.disabled = state.detailStatus !== 'ready' || state.phase === 'applying';
|
||||
setText(warning, state.warning);
|
||||
warning.hidden = !state.warning;
|
||||
if (state.prompts !== shownPrompts || state.detailStatus !== shownDetail) {
|
||||
shownPrompts = state.prompts;
|
||||
shownDetail = state.detailStatus;
|
||||
if (state.detailStatus === 'ready') prompts.setState({ prompts: state.prompts });
|
||||
else prompts.setState({ loading: state.detailStatus === 'loading', error: state.detailStatus === 'error' });
|
||||
}
|
||||
setDisabled(apply, state.detailStatus !== 'ready' || state.phase !== 'idle');
|
||||
setDisabled(later, state.phase === 'applying');
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
if (scope.signal.aborted) return;
|
||||
if (state.phase === 'done') return finish(state);
|
||||
const ready = state.status === 'ready';
|
||||
if (!ready) {
|
||||
setText(status, state.status === 'error' ? COPY.loadFailed : COPY.loading);
|
||||
status.setAttribute('role', state.status === 'error' ? 'alert' : 'status');
|
||||
if (!status.isConnected) main.append(status);
|
||||
if (state.status === 'error' && !retryLoad.isConnected) main.append(retryLoad);
|
||||
if (state.status !== 'error') retryLoad.remove();
|
||||
if (state.status === 'error' || !state.items.length) { notice.remove(); layout.remove(); continuation.remove(); }
|
||||
return;
|
||||
}
|
||||
status.remove();
|
||||
retryLoad.remove();
|
||||
setText(noticeTitleNode, noticeTitle(state.total));
|
||||
setText(noticeText, state.total ? COPY.notice : COPY.empty); // D86
|
||||
if (!notice.isConnected) main.append(notice);
|
||||
if (!state.items.length) { layout.remove(); continuation.remove(); return; }
|
||||
if (!layout.isConnected) notice.after(layout);
|
||||
renderCards(state);
|
||||
renderPanel(state);
|
||||
if (state.nextCursor) {
|
||||
setText(moreText, `${state.items.length.toLocaleString('en-US')} of ${state.total.toLocaleString('en-US')} images`);
|
||||
setDisabled(more, state.more === 'loading');
|
||||
if (state.more === 'error') setText(moreText, COPY.moreFailed);
|
||||
if (!continuation.isConnected) layout.after(continuation);
|
||||
} else continuation.remove();
|
||||
}
|
||||
|
||||
let finished = false;
|
||||
function finish(state) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (state.toast) {
|
||||
showWorkspaceToast(state.toast);
|
||||
onDecision();
|
||||
}
|
||||
// Ruling R7: Apply association and Leave for later both return to Images.
|
||||
navigateTo('#/images');
|
||||
}
|
||||
|
||||
scope.listen(retryLoad, 'click', () => controller.load());
|
||||
scope.listen(more, 'click', () => { if (!isDisabled(more)) controller.loadMore(); });
|
||||
scope.listen(select, 'change', () => controller.choose(select.value));
|
||||
scope.listen(apply, 'click', () => { if (!isDisabled(apply)) controller.apply(); });
|
||||
scope.listen(later, 'click', () => { if (!isDisabled(later)) controller.leave(); });
|
||||
scope.listen(tagButton, 'click', () => {
|
||||
const item = controller.state.items.find(entry => entry.id === controller.state.selectedId);
|
||||
if (isDisabled(tagButton) || !item?.target) return;
|
||||
openImageTagging({ api, scope, image: imageOf(item), source: 'review', trigger: tagButton });
|
||||
});
|
||||
scope.listen(grid, 'click', event => {
|
||||
const card = event.target.closest('article[data-review-id]');
|
||||
if (!card) return;
|
||||
const { item } = card;
|
||||
const button = event.target.closest('button');
|
||||
if (!button || isDisabled(button)) return;
|
||||
if (isTagButton(button)) {
|
||||
openImageTagging({ api, scope, image: imageOf(item), source: 'review', trigger: button });
|
||||
return;
|
||||
}
|
||||
if (button.matches('.image-open')) {
|
||||
const items = controller.state.items.filter(entry => entry.url).map(imageOf);
|
||||
viewer.open({ items, selectedKey: imageKey(imageOf(item)), trigger: button });
|
||||
return;
|
||||
}
|
||||
// Info selects the item, as the concept's `inspect` does on this page (app.js a1244–1248).
|
||||
controller.select(item.id);
|
||||
});
|
||||
|
||||
let shownTotal = null;
|
||||
const reportTotal = state => {
|
||||
if (state.status !== 'ready' || state.phase === 'done' || state.total === shownTotal) return;
|
||||
shownTotal = state.total;
|
||||
onTotal?.(state.total);
|
||||
};
|
||||
scope.cleanup(controller.subscribe(render));
|
||||
scope.cleanup(controller.subscribe(reportTotal));
|
||||
render(controller.state);
|
||||
controller.load();
|
||||
connectEvents({ scope, connect, controller, live });
|
||||
return { controller };
|
||||
}
|
||||
|
||||
/**
|
||||
* The change stream while the page is mounted: open while the tab is visible (I4), reopened with a bounded backoff
|
||||
* after the browser gives up on it (I3), with `live` shown while it is down.
|
||||
*/
|
||||
function connectEvents({ scope, connect, controller, live }) {
|
||||
let events = null, timer = null, delay = REOPEN_FIRST_MS;
|
||||
const setLive = up => { live.hidden = up; };
|
||||
function close() {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
events?.close();
|
||||
events = null;
|
||||
}
|
||||
function open() {
|
||||
if (events || scope.signal.aborted || document.hidden) return;
|
||||
const source = events = connect(REVIEW_EVENTS_URL);
|
||||
source.addEventListener('ready', event => {
|
||||
if (source !== events || scope.signal.aborted) return;
|
||||
delay = REOPEN_FIRST_MS;
|
||||
setLive(true);
|
||||
controller.sync(readyVersion(event));
|
||||
});
|
||||
source.addEventListener('change', () => { if (source === events && !scope.signal.aborted) controller.refresh(); });
|
||||
source.addEventListener('error', () => {
|
||||
if (source !== events || scope.signal.aborted) return;
|
||||
setLive(false);
|
||||
// The browser retries a dropped connection itself; one it closed for good is reopened here.
|
||||
if (source.readyState !== 2) return; // EventSource.CLOSED
|
||||
events = null;
|
||||
source.close();
|
||||
timer = setTimeout(() => { timer = null; open(); }, delay);
|
||||
delay = Math.min(delay * 2, REOPEN_MAX_MS);
|
||||
});
|
||||
}
|
||||
scope.listen(document, 'visibilitychange', () => {
|
||||
if (document.hidden) close();
|
||||
else open();
|
||||
});
|
||||
scope.cleanup(close);
|
||||
open();
|
||||
}
|
||||
@@ -12,6 +12,10 @@ export const routes = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
// SAVED VIEWS' first row (#249 D51): Images filtered to favorites. No href, so Overview lists no card for it.
|
||||
{ id: 'favorites', label: 'Favorites' },
|
||||
// SAVED VIEWS' Needs review row (ruling R5): the concept's Metadata review page (app.js renderReview 749–760; its
|
||||
// breadcrumb is "Review", app.js render 1175–1188),
|
||||
// the queue of ambiguous sampler evidence. No href: no Overview card.
|
||||
{ id: 'review', label: 'Needs review', title: 'Review' },
|
||||
// The concept's Activity (app.js renderActivity 732–738): this server session's jobs. No href: no Overview card.
|
||||
{ id: 'activity', label: 'Activity' },
|
||||
// Native System (#249 System S2): `#/system/<section>`. Q12: Overview's card opens its Overview section (`native`:
|
||||
@@ -21,7 +25,7 @@ export const routes = [
|
||||
description: 'Configuration, maintenance and recovery—with browsing kept in the workspace.', action: 'Open System' },
|
||||
];
|
||||
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'system']);
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system']);
|
||||
|
||||
/**
|
||||
* System's sections in the concept's order (app.js `systemSections` 635–644); the concept's `general` is production's
|
||||
@@ -88,6 +92,8 @@ const NAV_COUNT_SOURCES = Object.freeze([
|
||||
['favorites', '/prompt_manager/images/output?mode=workspace&favorites=1&limit=1'],
|
||||
['library', '/prompt_manager/library?limit=1'],
|
||||
['tags', '/prompt_manager/vocabulary/tags?limit=1'],
|
||||
// Needs review (ruling R5): the queue's `total` is the exact pending count.
|
||||
['review', '/prompt_manager/review/items?limit=1'],
|
||||
]);
|
||||
|
||||
/** Route id -> count for the sidebar links; null when a page is missing or its total is not a count. */
|
||||
@@ -102,7 +108,7 @@ export function summarizeNavCounts(pages = {}) {
|
||||
* Read the counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read settles, so a
|
||||
* slow listing (the Images output scan) never holds back the cheap Library and Tags counts.
|
||||
*/
|
||||
/** The sidebar routes that carry a count (WORKSPACE, plus SAVED VIEWS' Favorites). */
|
||||
/** The sidebar routes that carry a count (WORKSPACE, plus SAVED VIEWS' Favorites and Needs review). */
|
||||
export const NAV_COUNT_ROUTES = Object.freeze(NAV_COUNT_SOURCES.map(([id]) => id));
|
||||
|
||||
/** The counts a confirmed Library or vocabulary write can change; the Images listing cannot. */
|
||||
@@ -110,6 +116,8 @@ export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']);
|
||||
|
||||
/** The count a confirmed favorite change can change. */
|
||||
export const FAVORITE_WRITE_COUNTS = Object.freeze(['favorites']);
|
||||
/** The count a review decision or a finished metadata scan can change. */
|
||||
export const REVIEW_WRITE_COUNTS = Object.freeze(['review']);
|
||||
// Trailing delay that coalesces quick star toggles into one Favorites count read (#249 M1).
|
||||
export const FAVORITE_COUNT_DELAY_MS = 300;
|
||||
|
||||
|
||||
@@ -322,9 +322,7 @@ function overviewNodes(overview, { scope, navigate, openStatistics }) {
|
||||
const review = operationRow({ scope, title: ambiguousTitle(overview.needs_review),
|
||||
description: Number.isSafeInteger(overview.needs_review?.pending)
|
||||
? 'Review metadata before assigning a positive / negative pair.' : NOT_AVAILABLE,
|
||||
label: 'Review metadata', onClick: () => {} });
|
||||
// D93: the row reads the queue's pending count; its action waits for the Needs review page (#288).
|
||||
review.querySelector('button').setAttribute('aria-disabled', 'true');
|
||||
label: 'Review metadata', onClick: () => navigate('#/review') });
|
||||
const [sourcesTitle, sourcesDescription] = sourcesCopy(overview.sources);
|
||||
const thumbnails = operationRow({ scope, title: THUMBNAILS_LOADING, description: 'Generate only missing previews when necessary.',
|
||||
label: 'Review thumbnails', onClick: go('sources') });
|
||||
|
||||
@@ -394,6 +394,17 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
|
||||
}
|
||||
|
||||
/* Needs review (#249 PR F): the concept's Metadata review page (app.js a749–760) in its own classes. */
|
||||
/* #249 D3: the review queue is not virtualized, so its cards stay in the concept grid's flow. */
|
||||
.image-grid.review-queue > .media-card { position: static; /* D3 */ }
|
||||
/* #249 D20: the prompt blocks' mount point is transparent, so the chosen path's sections sit in the panel as the
|
||||
concept's do. */
|
||||
.review-panel > .prompt-inspector { display: contents; /* D20 */ }
|
||||
/* D119: a file that cannot be served (no current root) has an inert, empty media button that keeps the concept's
|
||||
1.33 media area (app.js `.image-open img`), so its card lines up with the queue's other cards. */
|
||||
.review-queue .image-open[aria-disabled="true"] { cursor: not-allowed; aspect-ratio: 1.33; /* D119 */ }
|
||||
/* D119: a long file name in the panel wraps inside the panel, where the concept's sample name never overflows. */
|
||||
.review-panel > p.small { min-inline-size: 0; overflow-wrap: anywhere; /* D119 */ }
|
||||
/* Auto-tag selected images (#249 PR D, D85/D90): the Tag image dialog's model panel, then How to apply, a radio group
|
||||
in the concept's `.field` row whose radios keep their own size (the concept's `.field input` is a full-width text
|
||||
field) beside their label, as the concept's own radio rows (s1160 `.compare-item label`). */
|
||||
|
||||
Reference in New Issue
Block a user