Compare commits

..
Author SHA1 Message Date
Vito Sansevero db5a1dc131 fix(ci): write browser coverage without each script's source (#432)
Every raw browser coverage file repeated the full source of each mapped
script, about 84% of its bytes, and the frontend coverage merge ran out of
Node's heap at 1,884 files. c8 reads the source from the mapped file on disk,
so the entries keep only the URL, script id and functions.
2026-10-28 13:31:05 -07:00
164 changed files with 1236 additions and 14330 deletions
-4
View File
@@ -214,7 +214,3 @@ coverage/
# Old library files a Move library renamed (never deleted; #249 S6b)
*.moved-to-*
# Per-install key behind the workspace scopes and cursors (#430)
workspace-scope.key
.workspace-scope.key.*.tmp
+48 -757
View File
File diff suppressed because it is too large Load Diff
+16 -84
View File
@@ -5,7 +5,6 @@ import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from .library_folders import folder_condition, list_library_folders
from .library_tokens import library_edit_token
_ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id=p.id)
@@ -122,54 +121,7 @@ def _archive(value: str) -> str:
return ARCHIVE_SCOPES[value]
# How the Tags filter matches (#444): every tag (AND), any of them (OR), or
# parents without any tag.
TAG_MODES = ("all", "any", "untagged")
# A parent that no tag's Browse returns (#444 "Untagged prompts"): no tag of its
# own and no active pair carrying one. The vocabulary's untagged count uses it too.
UNTAGGED = (
"NOT EXISTS(SELECT 1 FROM prompt_tags ut WHERE ut.prompt_id=p.id) "
"AND NOT EXISTS(SELECT 1 FROM prompt_pairs up JOIN prompt_pair_tags upt "
"ON upt.pair_id=up.id WHERE up.positive_prompt_id=p.id AND up.archived=0)"
)
def _tag_mode(tag_mode: object, tags: list[str]) -> str:
if not isinstance(tag_mode, str) or tag_mode not in TAG_MODES:
raise ValueError("tag_mode must be one of " + ", ".join(TAG_MODES))
if tag_mode == "untagged" and tags:
raise ValueError("Untagged prompts cannot also filter by tags")
return tag_mode
def _tag_conditions(tags: list[str], any_tag: bool) -> tuple[list, list, list]:
"""Parent-arm and pair-arm tag conditions and the tags' parameters."""
parent_exists = (
"EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.prompt_id=p.id AND t.name{test})"
)
pair_exists = (
"EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.pair_id=pp.id AND t.name{test})"
)
if any_tag and tags:
# One condition per arm: the card, or one active pair, carries any tag.
test = " IN (" + ",".join("?" for _ in tags) + ")"
return [parent_exists.format(test=test)], [pair_exists.format(test=test)], tags
return (
[parent_exists.format(test="=?") for _ in tags],
[pair_exists.format(test="=?") for _ in tags],
tags,
)
def _filter(
text: str,
category: str | None,
tags: list[str],
tag_mode: str = "all",
folder_prefixes: list[str] | None = None,
) -> tuple[str, list]:
def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list]:
if not isinstance(text, str) or len(text) > 500:
raise ValueError("Search text must be a string of at most 500 characters")
if category is not None and (not isinstance(category, str) or len(category) > 200):
@@ -180,8 +132,6 @@ def _filter(
or any(not isinstance(tag, str) or not tag or len(tag) > 200 for tag in tags)
):
raise ValueError("Invalid library tags")
mode = _tag_mode(tag_mode, tags)
untagged = mode == "untagged"
parent, pair, parent_params, pair_params = [], ["pp.archived=0"], [], []
if text:
parent.append("(p.text LIKE ? ESCAPE '\\' OR ls.title LIKE ? ESCAPE '\\')")
@@ -196,11 +146,17 @@ def _filter(
pair.append("pp.category=?")
parent_params.append(category)
pair_params.append(category)
parent_tags, pair_tags, tag_params = _tag_conditions(tags, mode == "any")
parent.extend(parent_tags)
pair.extend(pair_tags)
parent_params.extend(tag_params)
pair_params.extend(tag_params)
for tag in tags:
parent.append(
"EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.prompt_id=p.id AND t.name=?)"
)
pair.append(
"EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.pair_id=pp.id AND t.name=?)"
)
parent_params.append(tag)
pair_params.append(tag)
condition = (
"("
+ (" AND ".join(parent) or "1=1")
@@ -212,17 +168,7 @@ def _filter(
+ " AND ".join(pair)
+ ")"
)
if untagged:
# A whole-card condition: inside the parent-or-pair OR an untagged pair
# would let a tagged parent through.
condition = f"({condition}) AND {UNTAGGED}"
params = [*parent_params, *pair_params]
if folder_prefixes:
# A whole-card condition around both arms: images link to the parent.
folder, folder_params = folder_condition(folder_prefixes)
condition = f"({condition}) AND {folder}"
params.extend(folder_params)
return f"({_ELIGIBLE}) AND ({condition})", params
return f"({_ELIGIBLE}) AND ({condition})", [*parent_params, *pair_params]
class LibraryOperations:
@@ -305,8 +251,6 @@ class LibraryOperations:
text: str = "",
category: str | None = None,
tags: list[str] | None = None,
tag_mode: str = "all",
folder_prefixes: list[str] | None = None,
sort: str = "newest",
limit: int = 40,
after: dict | None = None,
@@ -314,9 +258,7 @@ class LibraryOperations:
_limit(limit)
if sort not in _ORDERS:
raise ValueError("Unknown library sort order")
where, params = _filter(
text, category, [] if tags is None else tags, tag_mode, folder_prefixes
)
where, params = _filter(text, category, [] if tags is None else tags)
if after is not None:
if (
not isinstance(after, dict)
@@ -369,8 +311,6 @@ class LibraryOperations:
"items": rows,
"total": total,
"variant_total": variant_total,
# Echoed so a client can tell this read applied its tag match.
"tag_mode": tag_mode,
"has_more": has_more,
"next_key": next_key,
}
@@ -381,8 +321,6 @@ class LibraryOperations:
text: str = "",
category: str | None = None,
tags: list[str] | None = None,
tag_mode: str = "all",
folder_prefixes: list[str] | None = None,
sort: str = "newest",
limit: int = 40,
after: dict | None = None,
@@ -395,8 +333,6 @@ class LibraryOperations:
text=text,
category=category,
tags=tags,
tag_mode=tag_mode,
folder_prefixes=folder_prefixes,
sort=sort,
limit=limit,
after=after,
@@ -527,13 +463,9 @@ class LibraryOperations:
"categories": [row["category"] for row in rows],
}
def get_library_options(self, folder_roots=None) -> dict:
"""Categories; with *folder_roots* (root spellings) also ``folders``."""
def get_library_options(self) -> dict:
with self.library_snapshot() as (conn, epoch, _ceiling):
result = self._get_library_options(conn, epoch)
if folder_roots is not None:
result["folders"] = list_library_folders(conn, folder_roots, _ELIGIBLE)
return result
return self._get_library_options(conn, epoch)
def get_library_previews(self, positive_ids: list[int]) -> dict[int, list[dict]]:
"""Read at most two links per parent in one bounded page query."""
-142
View File
@@ -21,10 +21,6 @@ _UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
_TOKEN = re.compile(r"[0-9a-f]{64}")
# Parent metadata stored on the prompt row; title lives in prompt_library_state.
_PROMPT_COLUMNS = ("category", "rating", "notes")
# Ids per IN (...) read: well under SQLite's oldest bound-parameter limit (999).
_BULK_CHUNK = 500
# The tags one prompt may carry: what the editor's save accepts (``validate_tags``).
MAX_LIBRARY_TAGS = 20
class LibraryCommandError(Exception):
@@ -89,13 +85,6 @@ def _validate_epoch(expected_epoch: str) -> None:
raise ValueError("expected_epoch must be a canonical UUID")
def _validate_bulk_ids(prompt_ids: list[int]) -> None:
if not isinstance(prompt_ids, list) or not prompt_ids:
raise ValueError("prompt_ids must be a non-empty list")
if any(type(pid) is not int or not 1 <= pid <= 2**63 - 1 for pid in prompt_ids):
raise ValueError("prompt_ids must be positive integers")
def _validate_guards(positive_id: int, expected_epoch: str, if_match: str) -> None:
if type(positive_id) is not int or not 1 <= positive_id <= 2**63 - 1:
raise ValueError("Invalid positive prompt ID")
@@ -319,134 +308,3 @@ class LibraryCommandOperations:
positive_created=False,
pair_created=pair_created,
)
def bulk_set_library_category(
self, prompt_ids: list[int], category: str, *, expected_epoch: str
) -> dict:
"""Set one category on many Library parents, all or nothing (#437).
Guarded by the storage epoch the page read. Only rows whose category
differs are written (their ``updated_at`` and edit token change) and
registered as Library parents, as the editor's PATCH does; ids
that are missing or are not Library parents count as not found.
"""
_validate_epoch(expected_epoch)
_validate_bulk_ids(prompt_ids)
prompt_ids = list(dict.fromkeys(prompt_ids))
if not isinstance(category, str) or not category.strip():
raise ValueError("Category is required")
validate_category(category)
category = category.strip()
with self._pair_transaction() as conn:
epoch = self._library_epoch(conn, expected_epoch)
current = {}
for start in range(0, len(prompt_ids), _BULK_CHUNK):
chunk = prompt_ids[start : start + _BULK_CHUNK]
marks = ",".join("?" for _ in chunk)
current.update(
conn.execute(
f"SELECT p.id,p.category FROM prompts p " # nosec B608
f"WHERE p.id IN ({marks}) AND {_ELIGIBLE}",
chunk,
).fetchall()
)
changed = [pid for pid, value in current.items() if value != category]
for pid in changed:
self._ensure_library_parent(conn, pid)
now = utc_now_iso()
conn.executemany(
"UPDATE prompts SET category=?,updated_at=? WHERE id=?",
[(category, now, pid) for pid in changed],
)
return {
"library_epoch": epoch,
"category": category,
"updated": len(changed),
"unchanged": len(current) - len(changed),
"not_found": len(prompt_ids) - len(current),
}
def bulk_add_library_tags(
self, prompt_ids: list[int], tags: list[str], *, expected_epoch: str
) -> dict:
"""Add tags to many Library parents, all or nothing (#442).
Names are normalized once (trim, the vocabulary name rules, casefold
repeats folded, at most 20). A parent is given only the names it lacks,
compared with ``casefold`` against its own tags; registry rows are
found and inserted by their exact name, and only for names some parent
gets. A parent whose tags would pass 20 is skipped (``over_limit``) so
the editor can still save it. Pair and image tags are not touched.
"""
from .annotations import ImageTagLimitError, normalize_image_tags
_validate_epoch(expected_epoch)
_validate_bulk_ids(prompt_ids)
prompt_ids = list(dict.fromkeys(prompt_ids))
too_many = f"At most {MAX_LIBRARY_TAGS} tags can be added at once"
try:
tags = normalize_image_tags(tags)
except ImageTagLimitError:
# Its message names images; this is a prompt route.
raise ValueError(too_many) from None
if not tags:
raise ValueError("tags must name at least one tag")
if len(tags) > MAX_LIBRARY_TAGS:
raise ValueError(too_many)
with self._pair_transaction() as conn:
epoch = self._library_epoch(conn, expected_epoch)
held = self._library_parent_tags(conn, prompt_ids)
additions, over_limit = {}, 0
for pid, names in held.items():
present = {name.casefold() for name in names}
missing = [name for name in tags if name.casefold() not in present]
if not missing:
continue
if len(names) + len(missing) > MAX_LIBRARY_TAGS:
over_limit += 1
continue
additions[pid] = missing
wanted = list(
dict.fromkeys(n for names in additions.values() for n in names)
)
tag_ids = self._ensure_tags(conn, wanted)
now = utc_now_iso()
for pid, names in additions.items():
conn.executemany(
"INSERT OR IGNORE INTO prompt_tags (prompt_id, tag_id) "
"VALUES (?, ?)",
[(pid, tag_ids[name]) for name in names],
)
self._ensure_library_parent(conn, pid)
conn.execute("UPDATE prompts SET updated_at=? WHERE id=?", (now, pid))
return {
"library_epoch": epoch,
"tags": tags,
"updated": len(additions),
"unchanged": len(held) - len(additions) - over_limit,
"not_found": len(prompt_ids) - len(held),
"over_limit": over_limit,
}
def _library_parent_tags(
self, conn: sqlite3.Connection, prompt_ids: list[int]
) -> dict[int, list[str]]:
"""The Library parents among ``prompt_ids`` and their own tag names."""
held: dict[int, list[str]] = {}
for start in range(0, len(prompt_ids), _BULK_CHUNK):
chunk = prompt_ids[start : start + _BULK_CHUNK]
marks = ",".join("?" for _ in chunk)
for (pid,) in conn.execute(
f"SELECT p.id FROM prompts p WHERE p.id IN ({marks}) " # nosec B608
f"AND {_ELIGIBLE}",
chunk,
):
held[pid] = []
for pid, name in conn.execute(
"SELECT pt.prompt_id,t.name FROM prompt_tags pt " # nosec B608
f"JOIN tags t ON t.id=pt.tag_id WHERE pt.prompt_id IN ({marks})",
chunk,
):
if pid in held:
held[pid].append(name)
return held
-114
View File
@@ -1,114 +0,0 @@
"""Library output-folder filter (#444): folder names, root prefixes and listing.
A folder is a ``/``-joined path relative to an output root, as Images' folder
filter and the legacy ``/prompt_manager/subfolders`` name them. A parent
matches a folder when one of its linked images (``generated_images.prompt_id``,
the link its previews and image count read) lies in that folder or below it,
under any spelling of any output root.
Matching compares ``generated_images.file_path``, which is always
``normcase(normpath(abspath(path)))`` (``models.normalize_image_path``), with
prefixes built by the same normalization here, so case folding is the
platform's own on both sides (Windows folds non-ASCII too, unlike SQLite's
``lower()``) and the comparison is an exact ``substr`` with no wildcards.
"""
import os
import sqlite3
_MAX_FOLDER = 1024
_MAX_PREFIXES = 64
def normalize_folder(value: object, pathmod=os.path) -> str:
"""The folder as ``a/b``, outer slashes dropped; empty means no filter.
``/`` always separates. ``\\`` separates only on Windows (``ntpath``): on
POSIX it is an ordinary character of a folder's name, and the listing
(``relative_folder``) names such a folder with it, so both agree.
``.``/``..``/empty segments, control characters and (on Windows) a drive
are refused: the value only ever names a folder below a root.
"""
if not isinstance(value, str) or len(value) > _MAX_FOLDER:
raise ValueError(f"folder must be text of at most {_MAX_FOLDER} characters")
windows = pathmod.sep == "\\"
folder = (value.replace("\\", "/") if windows else value).strip("/")
if not folder:
return ""
parts = folder.split("/")
if (
any(part in ("", ".", "..") for part in parts)
or (windows and ":" in parts[0])
or any(ord(char) < 32 or ord(char) == 127 for char in folder)
):
raise ValueError("folder must be a folder below an output root")
return folder
def _base(root: object, pathmod) -> str:
return pathmod.normcase(pathmod.normpath(str(root)))
def folder_prefixes(roots, folder: str, pathmod=os.path) -> list[str]:
"""One ``file_path`` prefix per root spelling for a normalized folder."""
prefixes = []
for root in roots:
joined = pathmod.join(_base(root, pathmod), *folder.split("/"))
prefix = pathmod.normcase(pathmod.normpath(joined)) + pathmod.sep
if prefix not in prefixes:
prefixes.append(prefix)
if not prefixes or len(prefixes) > _MAX_PREFIXES:
raise ValueError("No output folder to filter by")
return prefixes
def folder_condition(prefixes: list[str]) -> tuple[str, list]:
"""A condition on alias ``p``: one linked image under any prefix."""
tests = " OR ".join("substr(fg.file_path,1,?)=?" for _ in prefixes)
params = [value for prefix in prefixes for value in (len(prefix), prefix)]
return (
"EXISTS(SELECT 1 FROM generated_images fg WHERE fg.prompt_id=p.id "
f"AND ({tests}))",
params,
)
def relative_folder(image_path: str, roots, pathmod=os.path) -> str | None:
"""The folder of *image_path* below the first root spelling holding it.
The spelling of the stored path is kept (its case on Windows); ``None``
for an image directly in a root or outside every root.
"""
directory = pathmod.normpath(pathmod.dirname(image_path))
folded = pathmod.normcase(directory)
for root in roots:
base = _base(root, pathmod)
start = base if base.endswith(pathmod.sep) else base + pathmod.sep
if not folded.startswith(start):
continue
# normcase keeps the length for ordinary names; else show the folded form.
rest = (directory if len(directory) == len(folded) else folded)[len(start) :]
parts = rest.split(pathmod.sep)
if any(part in ("", ".", "..") for part in parts):
return None
return "/".join(parts)
return None
def list_library_folders(
conn: sqlite3.Connection, roots, eligible: str, pathmod=os.path
) -> list[str]:
"""Every folder (and ancestor) holding an image of an eligible parent."""
folders = set()
for (image_path,) in conn.execute(
"SELECT DISTINCT g.image_path FROM generated_images g "
"JOIN prompts p ON p.id=g.prompt_id WHERE " + eligible
):
if not isinstance(image_path, str) or not image_path:
continue
folder = relative_folder(image_path, roots, pathmod)
if folder is None:
continue
parts = folder.split("/")
folders.update("/".join(parts[:index]) for index in range(1, len(parts) + 1))
return sorted(folders, key=lambda value: (value.casefold(), value))
+1 -27
View File
@@ -56,11 +56,9 @@ from .models import BUSY_TIMEOUT_SECONDS, PromptModel
try:
from ..utils import library_location as ll
from ..utils.logging_config import get_logger
from ..utils.workspace_keys import KEY_FILE_NAME
except ImportError:
from utils import library_location as ll
from utils.logging_config import get_logger
from utils.workspace_keys import KEY_FILE_NAME
logger = get_logger("prompt_manager.database.library_move")
@@ -493,8 +491,7 @@ class _Move:
sweep_move_temps(target)
if ll.read_marker(target).get("state") == ll.MARKER_PENDING:
# An earlier move stopped before it switched: its files are ours.
names = (ll.DATABASE_NAME, ll.CONFIG_NAME, KEY_FILE_NAME)
for name in names + self.plan.backups:
for name in (ll.DATABASE_NAME, ll.CONFIG_NAME) + self.plan.backups:
_remove(os.path.join(target, name))
for suffix in ("-wal", "-shm", "-journal"):
_remove(os.path.join(target, ll.DATABASE_NAME + suffix))
@@ -539,7 +536,6 @@ class _Move:
raise MoveFailed("copy-failed") from error
_check_copies([(self.plan.config_path, temp, expected)])
copies = copies + [(self.plan.config_path, temp, expected, ll.CONFIG_NAME)]
copies = copies + self._key_copy()
self.marker = write_marker(
target, ll.MARKER_PENDING, self.plan.db_path, self.plan.config_path
)
@@ -560,28 +556,6 @@ class _Move:
self.checkpoint("marked")
return files
def _key_copy(self) -> list:
"""The workspace key's verified copy (#430), or none: it is re-creatable.
Without it the moved library starts with a new key, so pages left open
across the restart refresh once; that never fails the move.
"""
source = os.path.join(self.plan.source_dir, KEY_FILE_NAME)
if not os.path.isfile(source):
return []
temp = self.temp(".key")
try:
expected = _hash_copy(source, temp)
_check_copies([(source, temp, expected)])
except (OSError, MoveFailed) as error:
_remove(temp)
logger.warning(
f"Library move: the workspace key was not copied ({error}); "
"open workspace pages refresh once after the restart"
)
return []
return [(source, temp, expected, KEY_FILE_NAME)]
def cleanup(self) -> None:
if self.moved:
return
+15 -111
View File
@@ -1,14 +1,7 @@
"""Atomic metadata enrichment for a single known generation context.
Also the helpers both ingestion store paths share (#247): the file's link
rows and the one-prompt-per-file rule (``file_link_rows``,
``link_conflict``) and the marks a prompt created by ingestion carries
(``Marks``, ``mark_created``).
"""
"""Atomic metadata enrichment for a single known generation context."""
import os
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict
from .models import normalize_image_path
from .operations import _coerce_file_info
@@ -44,85 +37,16 @@ def blank_positive(resolution: Dict[str, Any]) -> bool:
return not (isinstance(text, str) and text.strip())
@dataclass(frozen=True)
class Marks:
"""What a prompt ingestion creates carries, so it can be found and removed.
``note`` is formatted with ``name``, the file's name.
"""
category: str
tags: Tuple[str, ...]
note: str
SCAN_MARKS = Marks("scanned", ("auto-scanned",), "Auto-scanned from {name}")
RUN_MARKS = Marks("scanned", ("auto-scanned",), "Saved from a workflow run: {name}")
def mark_created(conn: Any, db: Any, prompt_id: int, marks: Marks, name: str) -> None:
"""Apply ``marks`` to a prompt row the caller just created on ``conn``."""
conn.execute(
"UPDATE prompts SET category = ?, notes = ? WHERE id = ?",
(marks.category, marks.note.format(name=name), prompt_id),
)
db._sync_prompt_tags(conn, prompt_id, list(marks.tags))
def file_link_rows(conn: Any, image_path: str) -> List[Any]:
"""The file's link rows, each with its prompt's hash.
Matched under the path and its resolved spelling (an output folder
reached through a symlink or junction), each normalized and raw;
deduplicated by row id.
"""
rows: Dict[Any, Any] = {}
for spelling in dict.fromkeys((image_path, _resolved(image_path))):
for row in conn.execute(
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
"JOIN prompts p ON p.id=gi.prompt_id "
"WHERE gi.file_path=? OR gi.image_path=?",
(normalize_image_path(spelling), spelling),
).fetchall():
rows.setdefault(row["id"], row)
return list(rows.values())
def _resolved(path: str) -> str:
try:
return os.path.realpath(path)
except (OSError, ValueError):
return path
def link_conflict(rows: List[Any], prompt_hash: str) -> bool:
"""One prompt per file: more than one row, or a row of another prompt."""
return len(rows) > 1 or bool(rows and rows[0]["positive_hash"] != prompt_hash)
def ingest_resolved_pair(
db: Any,
image_path: str,
metadata: Dict[str, Any],
resolution: Dict[str, Any],
*,
marks: Optional[Marks] = None,
db: Any, image_path: str, metadata: Dict[str, Any], resolution: Dict[str, Any]
) -> Dict[str, Any]:
"""Store a resolved pair and link the file to it in one transaction.
With ``marks`` (ingestion from files), each pair component this call
creates carries them; without (the PromptManager node path) components
are created bare, as always.
"""
state = resolution.get("state", "unresolved")
if state == "resolved" and blank_positive(resolution):
state = "unresolved"
if state != "resolved":
return {"status": state, "pairs_added": 0, "links_added": 0}
with db._pair_transaction() as conn:
return _ingest_resolved_pair_in(
conn, db, image_path, metadata, resolution, marks=marks
)
return _ingest_resolved_pair_in(conn, db, image_path, metadata, resolution)
def _conflict() -> Dict[str, Any]:
@@ -137,7 +61,6 @@ def _ingest_resolved_pair_in(
resolution: Dict[str, Any],
*,
provenance: Dict[str, Any] | None = None,
marks: Optional[Marks] = None,
) -> Dict[str, Any]:
"""``ingest_resolved_pair`` inside the caller's open ``_pair_transaction``.
@@ -146,8 +69,7 @@ def _ingest_resolved_pair_in(
negative is a ``conflict`` and nothing is written. The caller owns the
transaction, so its own later failure rolls the link back too.
``provenance`` replaces the link's default resolver provenance (Needs
review records the user's selection there). ``marks`` as in
``ingest_resolved_pair``.
review records the user's selection there).
"""
positive, negative = resolution["positive"], resolution["negative"]
positive_text = positive.get("storage_text", positive["text"])
@@ -160,8 +82,15 @@ def _ingest_resolved_pair_in(
"negative": negative,
}
encoded = dumps_strict(provenance, sort_keys=True, separators=(",", ":"))
images = file_link_rows(conn, image_path)
if link_conflict(images, generate_prompt_hash(positive_text)):
images = conn.execute(
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
"JOIN prompts p ON p.id=gi.prompt_id "
"WHERE gi.file_path=? OR gi.image_path=?",
(normalized, image_path),
).fetchall()
if len(images) > 1 or (
images and images[0]["positive_hash"] != generate_prompt_hash(positive_text)
):
return _conflict()
existing = images[0] if images else None
if existing:
@@ -186,12 +115,7 @@ def _ingest_resolved_pair_in(
generate_prompt_hash(negative_text) if negative_text.strip() else None,
),
).fetchone()
if marks is None:
pair_id = db._save_pair(conn, positive_text, negative_text)
else:
pair_id = _save_marked_pair(
conn, db, positive_text, negative_text, marks, os.path.basename(image_path)
)
pair_id = db._save_pair(conn, positive_text, negative_text)
pair = conn.execute("SELECT * FROM prompt_pairs WHERE id=?", (pair_id,)).fetchone()
pairs_added = int(prior_pair is None)
file_size, width, height, image_format = _coerce_file_info(metadata)
@@ -273,26 +197,6 @@ def _ingest_resolved_pair_in(
}
def _save_marked_pair(
conn: Any,
db: Any,
positive_text: str,
negative_text: str,
marks: Marks,
name: str,
) -> int:
"""``_save_pair`` that marks each component it creates."""
texts = [positive_text] + ([negative_text] if negative_text.strip() else [])
ids = []
for text in texts:
prompt_id, created = db._pair_component_outcome(conn, text)
if created:
mark_created(conn, db, prompt_id, marks, name)
ids.append(prompt_id)
negative_id = ids[1] if len(ids) > 1 else None
return db._save_pair_components(conn, ids[0], negative_id)[0]
def _document_form(conn: Any, values: Dict[str, Any]) -> Dict[str, Any]:
"""``values`` with the workflow and prompt graph texts stored once.
+6 -7
View File
@@ -164,7 +164,7 @@ def _runs_by_location(db: Any, names: List[str]) -> Optional[Dict[tuple, dict]]:
return found
def recorded_runs(
def _recorded_runs(
db: Any,
image_paths: Iterable[str],
output_root: Optional[str],
@@ -201,8 +201,7 @@ def recorded_runs(
return attributed
def named_prompt(db: Any, run_id: str) -> Optional[int]:
"""The one stored prompt every positive context of ``run_id`` names, else None."""
def _named_prompt(db: Any, run_id: str) -> Optional[int]:
contexts = db.get_execution_contexts(run_id).get("contexts") or []
named = {
context.get("positive_prompt_id")
@@ -229,11 +228,11 @@ def stored_run_prompts(
paths = list(dict.fromkeys(path for path in image_paths if path))
result: Dict[str, Optional[int]] = dict.fromkeys(paths)
prompts: Dict[str, Optional[int]] = {}
for path, (run_id, _nodes) in recorded_runs(
for path, (run_id, _nodes) in _recorded_runs(
db, paths, output_root, path_style
).items():
if run_id not in prompts:
prompts[run_id] = named_prompt(db, run_id)
prompts[run_id] = _named_prompt(db, run_id)
result[path] = prompts[run_id]
return result
@@ -276,7 +275,7 @@ def recorded_output(
) -> Optional[RecordedOutput]:
"""The recorded run facts for ``image_path``, else None (#386).
The file must be attributed to one run (``recorded_runs``: one run
The file must be attributed to one run (``_recorded_runs``: one run
reported its location and it was written during that run). Read-only; it
never links anything.
"""
@@ -287,7 +286,7 @@ def recorded_output(
if own_transaction: # outputs and texts from one state of history
conn.execute("BEGIN")
try:
found = recorded_runs(db, [image_path], output_root, path_style)
found = _recorded_runs(db, [image_path], output_root, path_style)
if image_path not in found:
return None
run_id, nodes = found[image_path]
-25
View File
@@ -33,14 +33,8 @@ MAX_TAG_LENGTH = 50
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
_MEDIA = ("all", "image", "video")
# How a Library view's Tags match (#444): every tag, any of them, or parents
# without tags ("untagged" never goes with tags; the listing refuses both).
_TAG_MODES = ("all", "any", "untagged")
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
_SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}")
# A collection id as the listing's ``collection`` query takes it (#331): none
# ("") or 1 to 9 digits without a leading zero.
_COLLECTION = re.compile(r"|[1-9][0-9]{0,8}")
FALLBACK_NAME = "Saved view"
@@ -61,18 +55,12 @@ _STATE_FIELDS = {
"tags": ("tags", None, ()),
# The Favorites page's filter (#249 D51): a view saved there reopens it.
"favorites": ("bool", None, False),
# A view saved on a collection page (#331 D140) reopens that
# collection; never together with favorites.
"collection": ("collection", None, ""),
},
"library": {
"q": ("text", 500, ""),
"category": ("text", 100, ""),
"sort": ("choice", _LIBRARY_SORTS, "newest"),
"tags": ("tags", None, ()),
# #444: the Tags match and the output folder (relative, "/"-joined).
"tag_mode": ("choice", _TAG_MODES, "all"),
"folder": ("text", 1024, ""),
},
}
@@ -137,8 +125,6 @@ def _validate_value(key: str, kind: str, limit, value: object) -> None:
raise SavedViewInvalid(f"State '{key}' is not a supported value")
if kind == "source" and not _SOURCE.fullmatch(value):
raise SavedViewInvalid(f"State '{key}' must be 'all' or a source index")
if kind == "collection" and not _COLLECTION.fullmatch(value):
raise SavedViewInvalid(f"State '{key}' must be empty or a collection id")
if kind == "text" and (len(value) > limit or _CONTROL.search(value)):
raise SavedViewInvalid(f"State '{key}' is too long or has control characters")
@@ -156,10 +142,6 @@ def encode_state(page: object, state: object) -> str:
)
for key, (kind, limit, _default) in fields.items():
_validate_value(key, kind, limit, state[key])
if state.get("favorites") is True and state.get("collection"):
raise SavedViewInvalid("A view cannot filter by favorites and a collection")
if state.get("tag_mode") == "untagged" and state.get("tags"):
raise SavedViewInvalid("A view cannot list untagged prompts and filter tags")
encoded = json.dumps(
{key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":")
)
@@ -199,13 +181,6 @@ def normalize_state(page: str, raw: object) -> dict:
except SavedViewInvalid:
value = default
state[key] = list(value) if kind == "tags" else value
# Never written by this build; a stored row with both opens the collection,
# as the listing does for a URL with both (#331 D140).
if state.get("favorites") is True and state.get("collection"):
state["favorites"] = False
# Never written by this build either: the tags win.
if state.get("tag_mode") == "untagged" and state.get("tags"):
state["tag_mode"] = "all"
return state
-87
View File
@@ -1,87 +0,0 @@
"""Link an output file to the prompt of one text, creating it when new (#247).
The store step of ingestion from file metadata when the decision is a
positive text (``utils.file_prompt``). The prompt insert and the link run in
one ``BEGIN IMMEDIATE`` transaction: two writers never store one text twice,
a failed link leaves no new prompt behind, and a file stays linked to one
prompt (a file already linked to another prompt is a ``conflict``).
"""
import logging
import os
from typing import Any, Dict, Optional, Tuple
from .pair_ingestion import Marks, file_link_rows, link_conflict, mark_created
try:
from ..utils.hashing import generate_prompt_hash
except ImportError:
from utils.hashing import generate_prompt_hash
_LOGGER = logging.getLogger("prompt_manager.database.scanned_prompts")
def store_scanned_prompt_or_raise(
db: Any, path: str, canonical: Dict[str, Any], text: str, marks: Marks
) -> Tuple[str, Optional[int]]:
"""``(status, prompt id)`` after linking ``path`` to ``text``'s prompt.
``added`` (prompt created, carrying ``marks``), ``linked`` (stored text
reused), ``unchanged`` (already linked to it) or ``conflict`` (linked to
another prompt; nothing written, id None). Database errors are raised
and roll the whole store back, so a caller can run it again.
"""
with db._pair_transaction() as conn:
rows = file_link_rows(conn, path)
if link_conflict(rows, generate_prompt_hash(text)):
return "conflict", None
if rows:
return "unchanged", rows[0]["prompt_id"]
prompt_id, created = db._pair_component_outcome(conn, text)
if created:
mark_created(conn, db, prompt_id, marks, os.path.basename(path))
db._insert_image_link(conn, prompt_id, path, canonical)
return ("added" if created else "linked"), prompt_id
def store_scanned_prompt(
db: Any, path: str, canonical: Dict[str, Any], text: str, marks: Marks
) -> Tuple[str, Optional[int]]:
"""The scan's form: an error is logged and reported as ``("found", None)``.
A write that failed because Move library switched runs again on the new
library (``survive_library_switch``); the rerun re-checks the link rows.
"""
try:
return db.survive_library_switch(
lambda: store_scanned_prompt_or_raise(db, path, canonical, text, marks)
)
except Exception as error:
_LOGGER.error(
f"Failed to store the prompt of {os.path.basename(path)}: {error}"
)
return "found", None
def link_stored_prompt_or_raise(
db: Any, path: str, canonical: Dict[str, Any], prompt_id: int
) -> Optional[str]:
"""Link ``path`` to the stored prompt ``prompt_id`` (recorded history, #342).
``linked``, ``unchanged`` (already linked to it), ``conflict`` (linked to
another prompt; nothing written) or None when the prompt is gone. One
``BEGIN IMMEDIATE`` transaction; database errors are raised.
"""
with db._pair_transaction() as conn:
rows = file_link_rows(conn, path)
if len(rows) > 1 or (rows and rows[0]["prompt_id"] != prompt_id):
return "conflict"
if rows:
return "unchanged"
exists = conn.execute(
"SELECT 1 FROM prompts WHERE id = ?", (prompt_id,)
).fetchone()
if exists is None:
return None
db._insert_image_link(conn, prompt_id, path, canonical)
return "linked"
+10 -133
View File
@@ -20,7 +20,7 @@ counted, hashed into the token and renamed with the prompts and pairs.
import sqlite3
from .library import _ELIGIBLE, UNTAGGED, _escape, _limit
from .library import _ELIGIBLE, _limit
from .library_commands import _TOKEN, LibraryCommandError, _validate_epoch
from .library_tokens import canonical_token
from .models import utc_now_iso
@@ -38,7 +38,7 @@ _IMAGE_FILES = (
)
def _tag_rows(page: str, order: str = "page.id") -> str:
def _tag_rows(page: str) -> str:
"""Count rows for the registry rows selected by ``page`` (id, name)."""
# Eligible parent components carrying a tag in the page, plus the
# positive parent of every active pair carrying it (always eligible).
@@ -57,45 +57,11 @@ def _tag_rows(page: str, order: str = "page.id") -> str:
"COALESCE(MAX(images.n),0) AS image_count "
"FROM page LEFT JOIN owners ON owners.tag_id=page.id "
"LEFT JOIN images ON images.tag_id=page.id "
"GROUP BY page.id ORDER BY " + order
"GROUP BY page.id ORDER BY page.id"
)
_TAG_PAGE = _tag_rows("SELECT id,name FROM tags WHERE id>? ORDER BY id LIMIT ?")
# #444 tag sorts: sort -> (key expression on tags/counted, direction). Ties and
# "created" follow the id, so every order is total and keyset pages are exact.
TAG_SORTS = {
"created": (None, "ASC"),
"name": ("name COLLATE NOCASE", "ASC"),
"name-desc": ("name COLLATE NOCASE", "DESC"),
"prompts": ("prompt_count", "DESC"),
"prompts-asc": ("prompt_count", "ASC"),
}
_MAX_SEARCH = 200
_TAG_SEARCH = "name LIKE ? ESCAPE '\\'"
# Every matching tag ranked by the prompt count its row shows (the owners rule
# of ``_tag_rows``), then one keyset page of it with its image counts.
_RANKED_TAG_PAGE = (
"WITH owners(tag_id,pid) AS ("
"SELECT pt.tag_id,p.id FROM prompts p "
"JOIN prompt_tags pt ON pt.prompt_id=p.id WHERE " + _ELIGIBLE + " "
"UNION SELECT ppt.tag_id,pp.positive_prompt_id FROM prompt_pairs pp "
"JOIN prompt_pair_tags ppt ON ppt.pair_id=pp.id WHERE pp.archived=0),"
"counted AS (SELECT t.id,t.name,COUNT(DISTINCT o.pid) AS prompt_count "
"FROM tags t LEFT JOIN owners o ON o.tag_id=t.id WHERE {search} "
"GROUP BY t.id),"
"page AS (SELECT id,name,prompt_count FROM counted WHERE {after} "
"ORDER BY prompt_count {direction},id LIMIT ?),"
"images(tag_id,n) AS (" + _IMAGE_FILES + " "
"WHERE mit.tag_id IN (SELECT id FROM page) GROUP BY mit.tag_id) "
"SELECT page.id,page.name,page.prompt_count,"
"COALESCE(images.n,0) AS image_count FROM page "
"LEFT JOIN images ON images.tag_id=page.id "
"ORDER BY page.prompt_count {direction},page.id"
)
_UNTAGGED_COUNT = (
"SELECT COUNT(*) FROM prompts p WHERE (" + _ELIGIBLE + ") AND " + UNTAGGED
)
_TAG_ONE = _tag_rows("SELECT id,name FROM tags WHERE id=?")
# Every owner kind a tag can sit on, for the delete confirmation.
_TAG_IMPACT = (
@@ -244,28 +210,6 @@ def _tag_after(after: object) -> int:
return after
def _tag_search(search: object) -> str:
if not isinstance(search, str) or len(search) > _MAX_SEARCH:
raise ValueError(f"Tag search must be text of at most {_MAX_SEARCH} characters")
return search.strip()
def _sorted_tag_after(after: object, sort: str) -> tuple | None:
"""The ``[key, id]`` cursor of a name or prompt-count sort."""
if after is None:
return None
key_type = str if sort.startswith("name") else int
if (
not isinstance(after, list)
or len(after) != 2
or type(after[0]) is not key_type
or type(after[1]) is not int
or after[1] <= 0
):
raise ValueError("Invalid vocabulary cursor")
return after[0], after[1]
def _category_after(after: object) -> tuple[int, str] | None:
if after is None:
return None
@@ -487,80 +431,22 @@ class VocabularyOperations:
epoch: str,
*,
limit: int = 100,
after: object = None,
search: str = "",
sort: str = "created",
after: int | None = None,
) -> dict:
"""One page of tags matching ``search`` (a literal, case-insensitive
substring) in ``sort`` order, with the untagged parents' count."""
_limit(limit)
search = _tag_search(search)
if not isinstance(sort, str) or sort not in TAG_SORTS:
raise ValueError("sort must be one of " + ", ".join(TAG_SORTS))
where, params = "1=1", []
if search:
where, params = _TAG_SEARCH, [f"%{_escape(search)}%"]
total, untagged = conn.execute(
f"SELECT (SELECT COUNT(*) FROM tags WHERE {where}),({_UNTAGGED_COUNT})",
params,
).fetchone()
rows = [
dict(row) for row in self._tag_page(conn, where, params, sort, after, limit)
]
start = _tag_after(after)
total = conn.execute("SELECT COUNT(*) FROM tags").fetchone()[0]
rows = [dict(row) for row in conn.execute(_TAG_PAGE, (start, limit + 1))]
has_more = len(rows) > limit
rows = rows[:limit]
key, _direction = TAG_SORTS[sort]
next_key = None
if has_more:
last = rows[-1]
next_key = (
last["id"]
if key is None
else [
last["prompt_count" if key == "prompt_count" else "name"],
last["id"],
]
)
return {
"library_epoch": epoch,
"tags": rows,
"total": total,
"untagged_count": untagged,
# Echoed so a client can tell this read applied its search and sort.
"search": search,
"sort": sort,
"has_more": has_more,
"next_key": next_key,
"next_key": rows[-1]["id"] if has_more else None,
}
@staticmethod
def _tag_page(conn, where, params, sort, after, limit):
key, direction = TAG_SORTS[sort]
if key is None:
page = (
f"SELECT id,name FROM tags WHERE {where} AND id>? ORDER BY id LIMIT ?"
)
return conn.execute(
_tag_rows(page), [*params, _tag_after(after), limit + 1]
)
cursor = _sorted_tag_after(after, sort)
operator = ">" if direction == "ASC" else "<"
keyset, keys = "1=1", []
if cursor is not None:
keyset = f"({key}{operator}? OR ({key}=? AND id>?))"
keys = [cursor[0], cursor[0], cursor[1]]
if key == "prompt_count":
sql = _RANKED_TAG_PAGE.format(
search=where, after=keyset, direction=direction
)
return conn.execute(sql, [*params, *keys, limit + 1])
page = (
f"SELECT id,name FROM tags WHERE {where} AND {keyset} "
f"ORDER BY {key} {direction},id LIMIT ?"
)
order = f"page.{key} {direction},page.id"
return conn.execute(_tag_rows(page, order), [*params, *keys, limit + 1])
def _vocabulary_categories(
self,
conn: sqlite3.Connection,
@@ -604,18 +490,9 @@ class VocabularyOperations:
),
}
def list_vocabulary_tags(
self,
*,
limit: int = 100,
after: object = None,
search: str = "",
sort: str = "created",
):
def list_vocabulary_tags(self, *, limit: int = 100, after: int | None = None):
with self.library_snapshot() as (conn, epoch, _ceiling):
return self._vocabulary_tags(
conn, epoch, limit=limit, after=after, search=search, sort=sort
)
return self._vocabulary_tags(conn, epoch, limit=limit, after=after)
def list_vocabulary_categories(
self, *, limit: int = 100, after: list | None = None
+1 -2
View File
@@ -12,8 +12,7 @@ code. This plan proposes implementation order and acceptance gates.
- The native Prompt Library merged in [#210](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/210) for [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208): one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates.
- [Durable history storage](prompt-history-storage.md) records job attempts and outcomes from [#212](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/212), frozen prompt contexts from [#213](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/213), and observed run-owned output locators from [#214](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/214). Those locators do not verify file bytes or establish gallery-image associations. That association contract and the chronological execution timeline remain pending under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209).
- [#215](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/215) brings the shell, Images and natural-height Prompt Library presentation into the approved geometry and themes while retaining their existing operations. [#216](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/216) adds the shared current-file inspector with exact generation facts, scoped provenance, raw/download evidence and loaded-collection viewer controls, including a slideshow that pauses at native video. This delivery does not establish whole-application parity, change the default entry point or constitute a release. Recorded chronological run/context browsing continues under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209); image tagging/review, System consolidation and remaining organization capabilities continue under #196.
- Local Inspect (#249, PRs L1–L3) replaces the `#/inspect` handoff with the concept's native "Inspect a file" page: one local image or video previews inline and opens the shared viewer and full inspector on a record read entirely in the browser, with nothing uploaded, imported or stored. The legacy metadata page (`web/metadata.html`, served at `/prompt_manager/gallery.html`) stays reachable from Overview; its capabilities map to the shared inspector, including its one-click COPY ALL summary, which is the shared inspector's "Copy settings summary" (D117, LI-4). PR L3 ([#286](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/286)) shipped that button and local roles equal to the server's resolver (`web/js/workspace/local-resolver.mjs`, TESTING.md "Local roles (#249 PR L3, decision LI-1)"). Recorded deviations D60–D66 and D117 are in [TESTING.md](../TESTING.md).
- Collections ([#331](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/331), three PRs) adds named, hand-picked groups of images, stored in the library database by file location as favorites are: a COLLECTIONS sidebar group whose rows open Images filtered to one collection (`#/collection/<id>`) with rename and delete, Add to collection and Remove from collection in the Images selection bar, a Collections… checklist in the shared inspector for the side panel and the viewer, and saved views that keep a collection. Membership survives restarts, source reordering and System › Maintenance duplicate cleanup; no file is copied, moved or changed.
- Local Inspect (#249, PRs L1–L2) replaces the `#/inspect` handoff with the concept's native "Inspect a file" page: one local image or video previews inline and opens the shared viewer and full inspector on a record read entirely in the browser, with nothing uploaded, imported or stored. The legacy metadata page (`web/metadata.html`, served at `/prompt_manager/gallery.html`) stays reachable from Overview; its capabilities map to the shared inspector, except its one-click COPY ALL summary, whose ruled replacement ("Copy settings summary") is pending. Recorded deviations D60–D66 are in [TESTING.md](../TESTING.md). Local roles equal to the server's resolver follow in PR L3.
## Scope and compatibility
+1 -6
View File
@@ -275,12 +275,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, encoding_text
)
self._record_cached_item(
unique_id,
(encoding_text,),
storage_text,
fields=((text, prepend_text, append_text),),
)
self._record_cached_item(unique_id, (encoding_text,), storage_text)
return result
@classmethod
+3 -13
View File
@@ -243,24 +243,14 @@ class PromptManagerBase:
return False
def _record_cached_item(
self,
unique_id: Any,
outputs: tuple,
text: Any,
negative_text: Any = None,
fields: Optional[tuple] = None,
self, unique_id: Any, outputs: tuple, text: Any, negative_text: Any = None
) -> None:
"""Remember this execution's outputs and stored text for cached jobs (#347).
``fields`` are the ``(raw, prepend, append)`` texts this execution gave
``record_runtime_context`` for each output, so a cached job's history
can record them too (#403).
"""
"""Remember this execution's outputs and stored text for cached jobs (#347)."""
if unique_id is None:
return
try:
self._cached_runs().record_item(
unique_id, outputs, stored_facts(text, negative_text), fields
unique_id, outputs, stored_facts(text, negative_text)
)
except Exception as error:
self.logger.debug(f"Could not record the cached-run receipt: {error}")
+1 -5
View File
@@ -198,11 +198,7 @@ class PromptManagerPair(PromptManagerFocused):
type(self).__name__, negative_text, "", "", negative_text, role="negative"
)
self._record_cached_item(
unique_id,
(positive, negative_text),
text,
negative_text,
fields=((text, prepend_text, append_text), (negative_text, "", "")),
unique_id, (positive, negative_text), text, negative_text
)
return result
+1 -6
View File
@@ -246,12 +246,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, final_text
)
self._record_cached_item(
unique_id,
(final_text,),
storage_text,
fields=((text, prepend_text, append_text),),
)
self._record_cached_item(unique_id, (final_text,), storage_text)
return result
@classmethod
+82 -6
View File
@@ -74,11 +74,9 @@ from .autotag_routes import AutotagRoutesMixin
from .lora_integration import LoraIntegrationMixin
from .lora_import_routes import LoraImportRoutesMixin
from .image_categories import ImageCategoryRoutesMixin
from .source_rescope import SourceRescopeMixin
try:
from ...database.operations import PromptDatabase
from ...utils.file_prompt import parse_comfyui_prompt
from ...utils.logging_config import get_logger
from ...utils.video_metadata import is_video_path, read_video_metadata
except ImportError:
@@ -88,7 +86,6 @@ except ImportError:
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
)
from database.operations import PromptDatabase
from utils.file_prompt import parse_comfyui_prompt
from utils.logging_config import get_logger
from utils.video_metadata import is_video_path, read_video_metadata
@@ -343,7 +340,6 @@ class PromptManagerAPI(
LoraIntegrationMixin,
LoraImportRoutesMixin,
ImageCategoryRoutesMixin,
SourceRescopeMixin,
):
"""REST API handler for PromptManager operations and web interface.
@@ -710,7 +706,6 @@ class PromptManagerAPI(
self._register_lora_routes(routes)
self._register_lora_import_routes(routes)
self._register_image_category_routes(routes)
self._register_rescope_routes(routes)
# Register gzip compression middleware (once)
global _gzip_registered
@@ -1036,4 +1031,85 @@ class PromptManagerAPI(
def _parse_comfyui_prompt(self, metadata):
"""Parse ComfyUI and A1111 prompt data from metadata."""
return parse_comfyui_prompt(metadata)
result = {
"prompt": None,
"workflow": None,
"parameters": {},
"positive_prompt": None,
"negative_prompt": None,
}
# Check for A1111/Forge style parameters first.
# The chunk is one block: a possibly multi-line positive prompt, then a
# "Negative prompt:" section, then a trailing "Steps: ..." settings line.
# Taking lines[0] as the positive prompt truncates any multi-line prompt
# to one line, so split on the settings line instead.
if "parameters" in metadata:
import re as _re
params = metadata["parameters"]
body = params
settings = ""
_sm = _re.search(r"\nSteps:\s", body)
if _sm:
settings = body[_sm.start() + 1 :].strip()
body = body[: _sm.start()]
negative = None
_nm = _re.search(r"(?im)^\s*negative prompt:\s*", body)
if _nm:
negative = body[_nm.end() :].strip()
body = body[: _nm.start()]
elif body.lower().lstrip().startswith("negative prompt:"):
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
body = ""
result["positive_prompt"] = body.strip() or None
if negative:
result["negative_prompt"] = negative
# Store raw parameters too
result["parameters"]["parameters"] = params
if settings:
for _f in ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed"):
_fm = _re.search(
r"(?i)(?:^|,\s*)" + _re.escape(_f) + r":\s*([^,]+)", settings
)
if _fm:
result["parameters"][_f.lower().replace(" ", "_")] = _fm.group(
1
).strip()
# If no A1111 format found, proceed with ComfyUI parsing
if result["positive_prompt"] is None:
# Check for direct prompt field
if "prompt" in metadata:
try:
prompt_data = json.loads(metadata["prompt"])
result["prompt"] = prompt_data
except json.JSONDecodeError:
result["prompt"] = metadata["prompt"]
# Check for workflow
if "workflow" in metadata:
try:
workflow_data = json.loads(metadata["workflow"])
result["workflow"] = workflow_data
except json.JSONDecodeError:
result["workflow"] = metadata["workflow"]
# Check for other common ComfyUI fields
common_fields = [
"positive",
"negative",
"steps",
"cfg",
"sampler",
"scheduler",
"seed",
]
for field in common_fields:
if field in metadata:
try:
result["parameters"][field] = json.loads(metadata[field])
except json.JSONDecodeError:
result["parameters"][field] = metadata[field]
return result
+248 -153
View File
@@ -40,51 +40,45 @@ except ImportError:
from utils import exit_signal
try:
from ...database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair
from ...database.pair_ingestion import blank_positive, ingest_resolved_pair
from ...database.run_attribution import (
RecordedOutput,
host_output_directory,
named_prompt,
recorded_runs,
stored_run_prompt,
stored_run_prompts,
)
from ...database.scanned_prompts import (
link_stored_prompt_or_raise,
store_scanned_prompt,
)
from ...utils.file_prompt import (
NEVER,
NOT_SAVED,
NOT_SAVED_WIRED,
PAIR,
Policy,
decide_file_prompt,
from ...utils.generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
parse_page_json,
)
from ...utils.prompt_graph import metadata_opted_out, side_opted_out
from ...utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
from ...utils.review_capture import reviewable
except ImportError:
from database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair
from database.pair_ingestion import blank_positive, ingest_resolved_pair
from database.run_attribution import (
RecordedOutput,
host_output_directory,
named_prompt,
recorded_runs,
stored_run_prompt,
stored_run_prompts,
)
from database.scanned_prompts import (
link_stored_prompt_or_raise,
store_scanned_prompt,
)
from utils.file_prompt import (
NEVER,
NOT_SAVED,
NOT_SAVED_WIRED,
PAIR,
Policy,
decide_file_prompt,
from utils.generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
parse_page_json,
)
from utils.prompt_graph import metadata_opted_out, side_opted_out
from utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
from utils.review_capture import reviewable
try:
from ...database.library import display_title
from ...utils.hashing import generate_prompt_hash
except ImportError:
from database.library import display_title
from utils.hashing import generate_prompt_hash
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff")
VIDEO_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v", ".wmv")
@@ -264,7 +258,7 @@ SCAN_DETAILS = {
"skipped": "Skipped · already linked",
"ambiguous": "Needs review: more than one prompt could match",
"unresolved": "Needs review: the prompt could not be traced in the workflow",
"conflict": "Kept: already linked to a different prompt",
"conflict": "Needs review: already linked to a different prompt",
"failed": "Error: the prompt could not be stored",
"error": "Error: the file could not be processed (see the server log)",
"found": "Metadata found, but no prompt text to store",
@@ -331,6 +325,22 @@ def _file_size(path):
return None
def _with_file_size(canonical, media_file):
"""Record the file's size in ``file_info`` unless the metadata carries one.
Stored as ``generated_images.file_size``; the "new" scan scope skips a known
file only while its size is unchanged.
"""
info = canonical.get("file_info")
info = dict(info) if isinstance(info, dict) else {}
if info.get("size") is None:
size = _file_size(media_file)
if size is not None:
info["size"] = size
if info:
canonical["file_info"] = info
def _scan_roots(output_dirs):
"""(absolute root, public label, listing index), the most specific first.
@@ -586,56 +596,18 @@ class _BatchHistory:
"""A scan batch's recorded-history lookup, made on the first file needing it.
Most files link through their metadata and never ask; the first one that
does triggers one ``recorded_runs`` pass for the whole batch, from which
both the stored prompt (``get``) and the recorded saver (``recorded``)
of every file are read. A failure is logged and names nothing.
does triggers one lookup for the whole batch (``_recorded_runs_for``).
"""
def __init__(self, api, media_files):
self._api = api
self._files = [str(path) for path in media_files]
self._runs = None
self._prompts = {}
def _found(self):
if self._runs is None:
try:
self._runs = recorded_runs(
self._api.db, self._files, host_output_directory(), None
)
except Exception:
self._api.logger.warning(
"History attribution failed for a scan batch", exc_info=True
)
self._runs = {}
return self._runs
self._files = media_files
self._found = None
def get(self, path):
"""The stored prompt id history attributes to ``path``, else None."""
found = self._found().get(path)
if found is None:
return None
run_id = found[0]
if run_id not in self._prompts:
try:
self._prompts[run_id] = named_prompt(self._api.db, run_id)
except Exception:
self._api.logger.warning(
"History attribution failed for a scan batch", exc_info=True
)
self._prompts[run_id] = None
return self._prompts[run_id]
def recorded(self, path):
"""``RecordedOutput(saver, {})`` for an attributed file, else None.
``saver`` is set only when the run reported the file from one node.
"""
found = self._found().get(path)
if found is None:
return None
nodes = found[1]
return RecordedOutput(next(iter(nodes)) if len(nodes) == 1 else None, {})
if self._found is None:
self._found = self._api._recorded_runs_for(self._files)
return self._found.get(path)
class AdminRoutesMixin:
@@ -1700,6 +1672,24 @@ class AdminRoutesMixin:
note(media_file, status)
return counts
@staticmethod
def _graph_prompt_text(metadata):
"""The positive prompt the browser parser traces from the graphs, or None.
Records parse as the legacy pages parsed them (NaN reads as null).
"""
records = {}
for key in ("prompt", "workflow"):
value = metadata.get(key)
records[key] = parse_page_json(value) if isinstance(value, str) else value
try:
positive = extract_generation_params(records)["positivePrompt"]
except GenerationParamsError:
return None
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
return None
return positive.strip() or None
def _ingest_scanned_file(
self,
media_file,
@@ -1710,81 +1700,148 @@ class AdminRoutesMixin:
records=None,
history=None,
):
"""Store the prompt the shared per-file decision names (#247).
"""Enrich exact pairs; retain legacy positive-only import for absent pairs.
``utils.file_prompt.decide_file_prompt`` (scan policy) decides; this
stores the result. Returns the file's scan status, or None without
prompt metadata. With ``detailed``, a stored or linked file returns a
dict instead: its ``status``, the prompt's display ``title`` and, for
a resolved pair, the pair counts. When the resolver finds the file
ambiguous (whether or not a later step then links it) and
``ambiguous`` is a list, ``media_file`` is appended to it; only a file
that then returns ``"ambiguous"`` (nothing linked it) is a Needs
review case. An ambiguous result with nothing to choose
(``reviewable`` is False: fewer than two candidates carry a prompt)
goes to ``untraced`` instead and returns ``"unresolved"`` when nothing
links it. The parsed records of either ambiguous file go to
``records["canonical"]`` when ``records`` is a dict. ``history`` is
the batch's lazy recorded-history lookup.
Returns the file's scan status, or None without prompt metadata. With
``detailed``, a stored or linked file returns a dict instead: its
``status``, the prompt's display ``title`` and, for a resolved pair,
the pair counts. When the resolver finds the file ambiguous (whether
or not the browser parser then links it) and ``ambiguous`` is a list,
``media_file`` is appended to it; only a file that then returns
``"ambiguous"`` (nothing linked it) is a Needs review case. An
ambiguous result with nothing to choose (``reviewable`` is False: fewer
than two candidates carry a prompt) that nothing links returns
``"unresolved"`` instead, and ``media_file`` goes to ``untraced``.
The parsed records of either ambiguous file go to
``records["canonical"]`` when ``records`` is a dict.
"""
path = str(media_file)
if history is None: # one file: history is still asked once
history = _BatchHistory(self, [media_file])
decision = decide_file_prompt(
metadata,
file_name=Path(path).name,
file_size=functools.partial(_file_size, media_file),
in_output=False, # only the monitor policy reads it
lookup=functools.partial(history.recorded, path),
policy=Policy.SCAN,
)
if decision.kind in (NOT_SAVED, NOT_SAVED_WIRED):
# Save to library off (#90): no write, recorded-run fallback or
# Needs review item
return decision.kind
self._note_review(media_file, decision, ambiguous, untraced, records)
if decision.kind == NEVER:
if decision.try_recorded_run:
linked = self._link_recorded_run(
media_file, decision.canonical, detailed, history
)
if linked is not None:
return linked
return decision.unlinked_status
if decision.kind == PAIR:
# A rerun after a library move re-checks the link rows.
result = self.db.survive_library_switch(
lambda: ingest_resolved_pair(
self.db,
path,
decision.canonical,
decision.resolution,
marks=SCAN_MARKS,
)
if not metadata:
# No prompt metadata (--disable-metadata, bare savers): only the
# recorded run can say which prompt made it (#342)
return self._link_recorded_run(media_file, {}, detailed, history)
canonical = dict(metadata)
for key in ("prompt", "workflow"):
if isinstance(canonical.get(key), str):
try:
canonical[key] = json.loads(canonical[key])
except (ValueError, TypeError):
# Preserve malformed metadata for conservative resolver rejection.
pass
if metadata_opted_out(canonical):
# Save to library was off for the run's prompt: no prompt, link,
# recorded-run fallback or Needs review item (#90)
return "not_saved"
if metadata_opted_out(canonical, unreadable=True):
# The switch is wired from a node the file cannot show: only the
# live run (the node saw the value) can link it, never a scan
return "not_saved_wired"
if not canonical.get("prompt") and not canonical.get("parameters"):
return self._link_recorded_run(media_file, canonical, detailed, history)
_with_file_size(canonical, media_file)
resolution = resolve_prompt_pair(canonical)
if resolution["state"] == "resolved" and side_opted_out(
canonical.get("prompt"), resolution["negative"]
):
# Only the negative's node is off: store the positive alone (#90)
prompt_text = (
resolution["positive"]
.get("storage_text", resolution["positive"]["text"])
.strip()
)
if not detailed:
return result["status"]
title_text = decision.resolution["positive"]["text"]
return {**result, "title": self._scan_title(media_file, title_text)}
status, _prompt_id = store_scanned_prompt(
self.db, path, decision.canonical, decision.text, SCAN_MARKS
)
if not detailed or status not in SCAN_TITLED_DETAILS:
return status
return {"status": status, "title": self._scan_title(media_file, decision.text)}
@staticmethod
def _note_review(media_file, decision, ambiguous, untraced, records):
"""Hand an ambiguous file to the batch's Needs review lists."""
if decision.review == "untraced":
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
if not detailed or status not in SCAN_TITLED_DETAILS:
return status
return {
"status": status,
"title": self._scan_title(media_file, prompt_text),
}
if blank_positive(resolution):
# Only prepend/append text: no stored prompt can own it (#342)
resolution = {**resolution, "state": "unresolved"}
# Ambiguous without a choice: unresolved unless something links it.
unchoosable = resolution["state"] == "ambiguous" and not reviewable(resolution)
if unchoosable:
if untraced is not None:
untraced.append(media_file)
if records is not None:
records["canonical"] = decision.canonical
elif decision.review == "ambiguous" and ambiguous is not None:
records["canonical"] = canonical
elif resolution["state"] == "ambiguous" and ambiguous is not None:
ambiguous.append(media_file)
if records is not None:
records["canonical"] = decision.canonical
records["canonical"] = canonical
if resolution["state"] == "resolved":
result = ingest_resolved_pair(
self.db, str(media_file), canonical, resolution
)
if not detailed:
return result["status"]
return {
**result,
"title": self._scan_title(media_file, resolution["positive"]["text"]),
}
if canonical.get("prompt") and resolution["state"] in (
"ambiguous",
"unresolved",
):
# The resolver could not decide: link the prompt the inspector and the
# legacy pages show for this file (the browser parser, in Python).
# Graphs without a recognized sampler keep the legacy extractor below.
prompt_text = self._graph_prompt_text(metadata)
if not prompt_text:
linked = self._link_recorded_run(
media_file, canonical, detailed, history
)
if linked is not None:
return linked
return "unresolved" if unchoosable else resolution["state"]
elif resolution["state"] == "ambiguous":
return "unresolved" if unchoosable else "ambiguous"
elif resolution["state"] == "unresolved" and not isinstance(
canonical.get("parameters"), str
):
return resolution["state"]
elif canonical.get("prompt"):
# No node takes a positive or negative input (a guider or wrapper
# graph): the one wired text, never a guess (#252).
found = sampler_less_prompt(canonical["prompt"])
prompt_text = found["text"]
if prompt_text is None and isinstance(canonical.get("parameters"), str):
# A metadata saver also wrote A1111 text: its positive prompt.
parsed = self._parse_comfyui_prompt(metadata)["positive_prompt"]
prompt_text = parsed.strip() if parsed and parsed.strip() else None
if prompt_text is None:
linked = self._link_recorded_run(
media_file, canonical, detailed, history
)
if linked is not None:
return linked
return "unresolved" if found["state"] == "ambiguous" else "found"
else:
prompt_text = self._parse_comfyui_prompt(metadata)["positive_prompt"]
if not (prompt_text and prompt_text.strip()):
return "found"
prompt_text = prompt_text.strip()
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
if not detailed or status not in SCAN_TITLED_DETAILS:
return status
return {"status": status, "title": self._scan_title(media_file, prompt_text)}
def _recorded_runs_for(self, media_files):
"""``{path: prompt id or None}`` from recorded history for a scan batch.
One output lookup per chunk of names (``stored_run_prompts``), so a
batch never queries history once per file. An empty dict on failure.
"""
try:
return stored_run_prompts(
self.db, [str(path) for path in media_files], host_output_directory()
)
except Exception:
self.logger.warning(
"History attribution failed for a scan batch", exc_info=True
)
return {}
def _link_recorded_run(self, media_file, canonical, detailed=False, history=None):
"""Link a file recorded history attributes to one stored prompt (#342).
@@ -1793,25 +1850,31 @@ class AdminRoutesMixin:
``{"status", "title"}``), or None when history names no single stored
prompt for the file (``database.run_attribution``). Never creates a
prompt. Works without any prompt metadata in the file. ``history`` is
the batch's lookup (``_BatchHistory``). A file already linked to
another prompt is a ``conflict``: one prompt per file.
the batch's precomputed lookup; without it the file is looked up alone.
"""
if history is None:
history = _BatchHistory(self, [media_file])
prompt_id = history.get(str(media_file))
if history is not None:
prompt_id = history.get(str(media_file))
else:
try:
prompt_id = stored_run_prompt(
self.db, str(media_file), host_output_directory()
)
except Exception:
self.logger.warning(
f"History attribution failed for {media_file.name}",
exc_info=True,
)
return None
if prompt_id is None:
return None
try:
status = self.db.survive_library_switch(
lambda: link_stored_prompt_or_raise(
self.db, str(media_file), canonical, prompt_id
)
image_id = self.db.link_image_to_prompt(
prompt_id, str(media_file), metadata=canonical
)
except Exception as e:
self.logger.error(f"Failed to link {media_file.name} from history: {e}")
return "found"
if status is None:
return None
status = "linked" if image_id else "unchanged"
if not detailed or status not in SCAN_TITLED_DETAILS:
return status
prompt = self.db.get_prompt_by_id(prompt_id) or {}
@@ -1831,3 +1894,35 @@ class AdminRoutesMixin:
except Exception:
self.logger.warning("Scan row title could not be read; using its text")
return display_title((), text)
def _store_scanned_prompt(self, media_file, canonical, prompt_text):
"""Link ``media_file`` to ``prompt_text``'s prompt, saving it when new."""
prompt_hash = generate_prompt_hash(prompt_text)
existing = self.db.get_prompt_by_hash(prompt_hash)
if existing:
try:
image_id = self.db.link_image_to_prompt(
existing["id"], str(media_file), metadata=canonical
)
except Exception as e:
self.logger.error(
f"Failed to link {media_file.name} to existing prompt: {e}"
)
return "found"
return "linked" if image_id else "unchanged"
prompt_id = self.db.save_prompt(
prompt_text,
"scanned",
["auto-scanned"],
None,
f"Auto-scanned from {media_file.name}",
prompt_hash,
)
if not prompt_id:
return "found"
try:
self.db.link_image_to_prompt(prompt_id, str(media_file), metadata=canonical)
except Exception as e:
self.logger.error(f"Failed to link {media_file.name} to new prompt: {e}")
return "added"
+17 -74
View File
@@ -13,7 +13,6 @@ import re
import stat
import subprocess # nosec B404 - ffmpeg frames through run_encoder, fixed argv
import sys
import threading
import time as _time
import urllib.parse
from bisect import bisect_left, bisect_right, insort
@@ -82,7 +81,6 @@ try:
reveal_executor,
reveal_in_file_manager,
)
from ...utils.workspace_keys import gallery_root_scope, workspace_keys_for
except ImportError:
from database.image_collections import CollectionMissing
from database.image_favorites import MAX_KEY_LENGTH
@@ -107,7 +105,6 @@ except ImportError:
reveal_executor,
reveal_in_file_manager,
)
from utils.workspace_keys import gallery_root_scope, workspace_keys_for
IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
@@ -521,8 +518,7 @@ MAX_JSON_BODY_BYTES = 1024 * 1024
_READ_CHUNK = 64 * 1024
# Guards the first load of an API's workspace keys (#430, _workspace_keys).
_WORKSPACE_KEYS_LOCK = threading.Lock()
_WORKSPACE_CURSOR_SECRET = os.urandom(32)
def _workspace_root(query: dict, roots: list[Path]) -> int | None:
@@ -622,37 +618,15 @@ def _rated_key(entry: _Listed, rank: int) -> tuple:
return (-rank, *_workspace_key(entry, "rating"))
# Bump whenever the cursor's key tuple changes shape: a cursor now outlives a
# restart and an upgrade (#430), so an old shape must be refused, not parsed.
_CURSOR_VERSION = 2
_CURSOR_STALE = "The image collection changed; refresh the gallery"
class CursorStale(ValueError):
"""A well-formed cursor this listing cannot verify (#430).
Another key, another root list or other filters: the page refreshes its
collection instead of retrying the same cursor.
"""
def _cursor_mac(secret: bytes, scope: str, payload: bytes) -> bytes:
# The listing scope (it hashes every absolute root) is bound in the MAC
# only, never sent: the scope is 64 hex, so the "\n" join is unambiguous.
return hmac.new(secret, scope.encode() + b"\n" + payload, hashlib.sha256).digest()
def _workspace_cursor(key: tuple, scope: str, secret: bytes) -> str:
"""The signed keyset cursor of *key*; *secret* is the API's cursor key."""
def _workspace_cursor(key: tuple, scope: str) -> str:
payload = json.dumps(
{"v": _CURSOR_VERSION, "key": key}, separators=(",", ":"), ensure_ascii=False
{"v": 1, "scope": scope, "key": key}, separators=(",", ":"), ensure_ascii=False
).encode()
signature = _cursor_mac(secret, scope, payload)
signature = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
return base64.urlsafe_b64encode(signature + payload).decode().rstrip("=")
def _workspace_cursor_key(token: str, scope: str, order: str, secret: bytes) -> tuple:
"""The keyset key of *token*; CursorStale when it is not this listing's."""
def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
try:
if len(token) > 4096:
raise ValueError()
@@ -660,14 +634,13 @@ def _workspace_cursor_key(token: str, scope: str, order: str, secret: bytes) ->
token + "=" * (-len(token) % 4), altchars=b"-_", validate=True
)
signature, payload = raw[:32], raw[32:]
if len(signature) != 32 or not payload:
expected = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
if not hmac.compare_digest(signature, expected):
raise ValueError()
if not hmac.compare_digest(signature, _cursor_mac(secret, scope, payload)):
raise CursorStale(_CURSOR_STALE)
data = json.loads(payload)
key = data["key"]
if data["v"] != _CURSOR_VERSION or not isinstance(key, list):
raise CursorStale(_CURSOR_STALE)
if data["v"] != 1 or data["scope"] != scope or not isinstance(key, list):
raise ValueError()
types = (
(str, str, int, (float, int))
if order in ("name", "name-desc")
@@ -681,8 +654,6 @@ def _workspace_cursor_key(token: str, scope: str, order: str, secret: bytes) ->
):
raise ValueError()
return tuple(key)
except CursorStale:
raise
except (ValueError, KeyError, TypeError, UnicodeError, binascii.Error):
raise ValueError("Invalid gallery cursor; refresh the gallery") from None
@@ -1367,27 +1338,13 @@ def _in_subfolder(rel_dir, subfolder):
class ImageRoutesMixin:
"""Mixin providing image and gallery-related API endpoints."""
def _workspace_keys(self):
"""This API's scope and cursor keys, loaded once (#430).
They derive from the secret persisted beside the database, so a restart
keeps every scope and cursor an open page holds. The first call reads
(or writes) one small file; later calls return the cached keys, which
a live library move or restore does not change before a restart.
"""
keys = self.__dict__.get("_workspace_keys_cache")
if keys is None:
with _WORKSPACE_KEYS_LOCK:
keys = self.__dict__.get("_workspace_keys_cache")
if keys is None:
db = getattr(self, "db", None)
model = getattr(db, "model", None)
keys = workspace_keys_for(getattr(model, "db_path", None))
self.__dict__["_workspace_keys_cache"] = keys
return keys
def _gallery_root_scope(self, root: Path, index: int) -> str:
return gallery_root_scope(self._workspace_keys(), str(_canonical(root)), index)
payload = json.dumps(
["prompt-manager-gallery-root-v1", index, str(_canonical(root))],
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
return hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).hexdigest()
def _gallery_scope_error(
self, query: Mapping, roots: list[Path]
@@ -2051,8 +2008,6 @@ class ImageRoutesMixin:
limit, _offset = parse_page_params(request.query, default_limit=100)
except ValueError:
return bad_request("limit and offset must be integers")
# #430: the first use may create the key file; never on the event loop.
cursor_secret = (await self._run_in_executor(self._workspace_keys)).cursor
try:
root_index = _workspace_root(request.query, output_dirs)
filters = _workspace_filters(request.query)
@@ -2076,15 +2031,7 @@ class ImageRoutesMixin:
).hexdigest()
token = request.query.get("cursor")
after = (
_workspace_cursor_key(token, scope, filters["order"], cursor_secret)
if token
else None
)
except CursorStale as exc:
return web.json_response(
{"success": False, "code": "cursor-stale", "error": str(exc)},
status=400,
headers={"Cache-Control": "no-store"},
_workspace_cursor_key(token, scope, filters["order"]) if token else None
)
except ValueError as exc:
return bad_request(str(exc))
@@ -2525,11 +2472,7 @@ class ImageRoutesMixin:
positive_count = _COUNT_OMITTED
has_more = len(page) > limit
page = page[:limit]
next_cursor = (
_workspace_cursor(key(page[-1]), scope, self._workspace_keys().cursor)
if has_more
else None
)
next_cursor = _workspace_cursor(key(page[-1]), scope) if has_more else None
return (
[(entry.path, entry.mtime, entry.root, entry.index) for entry in page],
total,
+2 -52
View File
@@ -21,11 +21,9 @@ from .pairs import pair_response
try:
from ...database.library import ARCHIVE_SCOPES
from ...database.library_commands import LibraryCommandError
from ...database.library_folders import folder_prefixes, normalize_folder
except ImportError:
from database.library import ARCHIVE_SCOPES
from database.library_commands import LibraryCommandError
from database.library_folders import folder_prefixes, normalize_folder
_SECRET = os.urandom(32)
@@ -285,84 +283,36 @@ class LibraryRoutesMixin:
outputs = list(self._get_all_output_dirs())
return self._media_roots(outputs), len(outputs)
def _library_root_spellings(self) -> list[str]:
"""Every spelling of each output root (#444 folder filter): the
validated, resolved root and each configured or detected path that
resolves to it, so images saved through a symlinked root match."""
from ..config import GalleryConfig
roots = [str(root) for root in self._get_all_output_dirs()]
candidates = [
*GalleryConfig.MONITORING_DIRECTORIES,
self._find_comfyui_output_dir(),
*self._comfyui_output_dir_list(),
]
spellings = []
for root in roots:
for value in (root, *candidates):
if not value or not isinstance(value, (str, os.PathLike)):
continue
value = str(value)
same = value == root or os.path.normcase(
os.path.realpath(value)
) == os.path.normcase(root)
if same and value not in spellings:
spellings.append(value)
return spellings
@pair_response
async def get_library_options(self, request: web.Request) -> web.Response:
include = request.query.get("include", "")
if include not in ("", "folders"):
raise ValueError("include must be empty or 'folders'")
if include:
# Folders are read only when Refine opens (a walk of linked paths).
roots = await self._run_in_executor(self._library_root_spellings)
result = await self._run_in_executor(self.db.get_library_options, roots)
else:
result = await self._run_in_executor(self.db.get_library_options)
result = await self._run_in_executor(self.db.get_library_options)
return web.json_response({"success": True, **result})
@pair_response
async def list_library(self, request: web.Request) -> web.Response:
folder = normalize_folder(request.query.get("folder", ""))
options = {
"text": request.query.get("text", ""),
"category": request.query.get("category"),
"tags": sorted(set(request.query.getall("tag", []))),
"tag_mode": request.query.get("tag_mode", "all"),
"sort": request.query.get("sort", "newest"),
}
limit = _page_limit(request)
cursor = request.query.get("cursor")
roots = (
await self._run_in_executor(self._library_root_spellings) if folder else []
)
prefixes = folder_prefixes(roots, folder) if folder else None
def read() -> tuple[dict, dict]:
with self.db.library_snapshot(include_ceiling=True) as snapshot:
conn, epoch, ceiling = snapshot
# The folder and the roots it was resolved under join the scope.
scope = _scope(
{
"kind": "parents",
"projection": 2,
"epoch": epoch,
**options,
**({"folder": folder, "roots": roots} if folder else {}),
}
{"kind": "parents", "projection": 2, "epoch": epoch, **options}
)
result = self.db._list_library(
conn,
epoch,
ceiling,
**options,
folder_prefixes=prefixes,
limit=limit,
after=_decode(cursor, scope),
)
result["folder"] = folder
records = self.db.get_library_previews(
[parent["id"] for parent in result["items"]]
)
+1 -60
View File
@@ -8,10 +8,8 @@ import sqlite3
from aiohttp import web
try:
from ...database.library_commands import LibraryCommandError
from ...database.pairs import PairDependencyError
except ImportError:
from database.library_commands import LibraryCommandError
from database.pairs import PairDependencyError
from urllib.parse import quote
@@ -1130,14 +1128,7 @@ class PromptRoutesMixin:
async def bulk_add_tags(self, request):
"""Bulk add tags to prompts."""
try:
try:
data = await request.json()
except ValueError:
return bad_request("Invalid JSON body")
if not isinstance(data, dict):
return bad_request("Expected a JSON object")
if "expected_epoch" in data:
return await self._bulk_library_tags(data)
data = await request.json()
prompt_ids = data.get("prompt_ids", [])
new_tags = data.get("tags", [])
@@ -1189,8 +1180,6 @@ class PromptRoutesMixin:
)
if "images_filter" in data:
return await self._bulk_category_by_filter(data)
if "expected_epoch" in data:
return await self._bulk_library_category(data)
if not data.get("prompt_ids"):
return web.json_response(
{"success": False, "error": "No prompt IDs provided"}, status=400
@@ -1227,54 +1216,6 @@ class PromptRoutesMixin:
status=500,
)
async def _bulk_library_tags(self, data: dict) -> web.Response:
"""The workspace Library's fenced form (#442).
``{prompt_ids, tags, expected_epoch}``: prompt tags only, never image tags.
"""
return await self._fenced_library_write(
data, "tags", self.db.bulk_add_library_tags
)
async def _fenced_library_write(
self, data: dict, field: str, write
) -> web.Response:
"""One fenced Library bulk write: exact keys, checked ids, 409 on a restore."""
fields = {"prompt_ids", field, "expected_epoch"}
if set(data) != fields:
return bad_request(
"Request must contain exactly " + ", ".join(sorted(fields))
)
try:
prompt_ids = parse_bulk_prompt_ids(data["prompt_ids"])
result = await self._run_in_executor(
write, prompt_ids, data[field], expected_epoch=data["expected_epoch"]
)
except LibraryCommandError as exc:
return web.json_response(
{
"success": False,
"code": exc.code,
"library_epoch": exc.epoch,
"error": "Prompt storage was restored; refresh the library",
},
status=409,
)
except ValueError as exc:
return bad_request(str(exc))
return web.json_response({"success": True, **result})
async def _bulk_library_category(self, data: dict) -> web.Response:
"""The workspace Library's fenced form (#437).
``{prompt_ids, category, expected_epoch}``: a non-blank category on
the selected Library parents, refused with 409 ``storage-changed``
when prompt storage was restored since the page read its epoch.
"""
return await self._fenced_library_write(
data, "category", self.db.bulk_set_library_category
)
async def _bulk_category_by_filter(self, data: dict) -> web.Response:
"""Set the category on every linked positive a listing filter admits.
+1 -1
View File
@@ -3,7 +3,7 @@
The rule (maintainer test of #284/#288): a file is queued only when its
final outcome is ambiguous and unlinked. The strict resolver must find it
``ambiguous``, the scan's legacy fallback (the browser parser,
``graph_prompt_text``) must not link it, and no saved row may link it
``_graph_prompt_text``) must not link it, and no saved row may link it
already. A file the fallback links, an exact pair the ingester stored and an
already linked file are never queued. Neither is an ambiguous file with
nothing to choose: fewer than two of its candidates carry a prompt
-100
View File
@@ -1,100 +0,0 @@
"""Where a stale Images target is now (#430): ``POST /prompt_manager/images/rescope``.
A page addresses a listed file as ``{root_index, root_scope, path}``. When the
sources are renumbered its scope no longer names the root at that index, and
every per-file action answers 409 ``source-changed``. Before it gives up, the
page asks here. The target is mapped only when its scope is, at its old
index, the scope of one of the current roots: the server issued that scope
for exactly that folder, so the answer (the file's current listing
descriptor) names nothing the page could not already address. A removed or
replaced root, a scope of another key and a foreign scope stay refused.
Only the Images roots (``_get_all_output_dirs``) are mapped; a LoRA-linked
root's target is never re-scoped here.
"""
import hmac
from pathlib import Path
from aiohttp import web
from .images import (
_forbidden,
_is_thumbnail_cache_path,
_json_body_or_error,
_media_access_error,
)
from .media_target import media_target
from .prompts import bad_request
# Far above any real source count; keeps the index out of huge-int territory.
MAX_ROOT_INDEX = 4095
SOURCE_GONE = (
"The source of this image changed or was removed. Refresh the image "
"collection to find it again."
)
def _coded(status: int, code: str, message: str) -> web.Response:
return web.json_response(
{"success": False, "code": code, "error": message}, status=status
)
def _no_store(response: web.Response) -> web.Response:
response.headers["Cache-Control"] = "no-store"
return response
class SourceRescopeMixin:
"""The rescope route; composed into ``PromptManagerAPI``."""
def _register_rescope_routes(self, routes):
@routes.post("/prompt_manager/images/rescope")
async def rescope_route(request):
return _no_store(await self.rescope_image(request))
def _current_root_for(self, scope: str, old_index: int, roots: list) -> int | None:
"""The index of the first current root *scope* was issued for at *old_index*."""
for index, root in enumerate(roots):
if hmac.compare_digest(scope, self._gallery_root_scope(root, old_index)):
return index
return None
def _rescope_blocking(self, scope: str, old_index: int, relative: str):
"""The answer to one rescope (blocking: it resolves paths)."""
roots = list(self._get_all_output_dirs())
index = self._current_root_for(scope, old_index, roots)
if index is None:
return _coded(409, "source-changed", SOURCE_GONE)
root = Path(roots[index])
path = root / relative
denied = _media_access_error(path, [root])
if denied is not None:
return denied
try:
mtime = path.stat().st_mtime if path.is_file() else None
except OSError:
mtime = None
if mtime is None:
return _coded(404, "file-missing", "Image file not found")
[image] = self._format_workspace_page([(path, mtime, root, index)])
return web.json_response({"success": True, "image": image})
async def rescope_image(self, request) -> web.Response:
data, error = await _json_body_or_error(request)
if error is not None:
return error
if set(data) != {"target"}:
return bad_request("Send exactly one target")
target, error = media_target(data["target"])
if error is not None:
return error
old_index, scope, relative = target
if old_index > MAX_ROOT_INDEX:
return bad_request("Invalid gallery root")
if _is_thumbnail_cache_path(relative):
return _forbidden("Previews are not re-scoped; use the original")
return await self._run_in_executor(
self._rescope_blocking, scope, old_index, relative
)
+5 -18
View File
@@ -61,25 +61,18 @@ class VocabularyRoutesMixin:
)
async def _vocabulary_page(
self,
request: web.Request,
kind: str,
reader_name: str,
name: bool,
options: dict | None = None,
self, request: web.Request, kind: str, reader_name: str, name: bool
) -> web.Response:
limit = _vocabulary_limit(request)
cursor = request.query.get("cursor")
options = options or {}
def read() -> dict:
# The snapshot stays open while the cursor is decoded and encoded,
# so its epoch scope is the epoch the page was read from. A tag
# search or sort joins the scope: its cursor fits no other order.
# so its epoch scope is the epoch the page was read from.
with self.db.library_snapshot() as (conn, epoch, _ceiling):
scope = _scope({"kind": kind, "epoch": epoch, **options})
scope = _scope({"kind": kind, "epoch": epoch})
result = getattr(self.db, reader_name)(
conn, epoch, limit=limit, after=_decode(cursor, scope), **options
conn, epoch, limit=limit, after=_decode(cursor, scope)
)
result["next_cursor"] = _encode(
result.pop("next_key"), scope, name=name
@@ -91,14 +84,8 @@ class VocabularyRoutesMixin:
@pair_response
async def list_vocabulary_tags(self, request: web.Request) -> web.Response:
options = {
"search": request.query.get("search", "").strip(),
"sort": request.query.get("sort", "created"),
}
# Name keys are text: kept server side so the cursor stays small.
name = options["sort"].startswith("name")
return await self._vocabulary_page(
request, "vocabulary-tags", "_vocabulary_tags", name, options
request, "vocabulary-tags", "_vocabulary_tags", False
)
@pair_response
-108
View File
@@ -1,108 +0,0 @@
import {test,expect} from './fixtures.mjs';
import {SIZES,THEMES,seedConceptAt,openConcept,openProduction,diffInventory,mutate} from './concept-inventory.mjs';
// #331 D138: the Images selection bar's "Add to collection" dialog against the concept's own (`bulk-collection` →
// simpleInput, app.js a1392–1398 and a1138), on the concept-shaped fixture, at 1600x1100 and 390x844 in both themes.
// Paired screenshots are artifacts only; there are no PNG baselines.
test.use({locale:'en-US',timezoneId:'UTC'});
const CARDS='.gallery-body > .image-grid > .media-card';
/**
* Runs in the page: the open "Add selected images to collection" dialog: its title, the body's tree, the field's
* label, input and suggestions, and the actions, with the concept's geometry and tokens.
*/
function measureAddDialog() {
const dialog=document.querySelector('dialog#modal[open]');
const css=(node,...keys)=>node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null;
const name=node=>[node.localName,...[...node.classList].sort()].join('.');
const text=node=>node?.textContent.trim()??null;
const body=dialog.querySelector('#modal-body');
const field=body.querySelector(':scope > .field');
const label=field?.querySelector(':scope > label');
const input=field?.querySelector(':scope > input');
const list=input?.list??null;
const actions=body.querySelector(':scope > .dialog-actions');
const rect=dialog.getBoundingClientRect();
return {
title:text(dialog.querySelector('#modal-title')),
tree:[...body.children].map(name),
field:field&&{tree:[...field.children].map(name),height:Math.round(field.getBoundingClientRect().height),
...css(field,'display','flexDirection','rowGap','marginBottom')},
label:label&&{text:text(label),for:label.htmlFor,...css(label,'fontSize','fontWeight','color')},
input:input&&{id:input.id,type:input.type,value:input.value,maxLength:input.maxLength,list:input.getAttribute('list'),
...css(input,'height','paddingLeft','fontSize','color','backgroundColor','borderTopColor','borderTopLeftRadius')},
suggestions:list&&{id:list.id,display:getComputedStyle(list).display,options:[...list.options].map(option=>option.value)},
actions:{tree:[...actions.children].map(name),...css(actions,'display','justifyContent','columnGap','marginTop'),
buttons:[...actions.children].map(button=>({text:text(button),className:button.className,
...css(button,'backgroundColor','color','fontSize','paddingTop','paddingLeft','borderTopColor')}))},
width:Math.round(rect.width),height:Math.round(rect.height),
};
}
/**
* D138: the action is "Add" (the concept's "Apply in prototype"), the field starts empty (no sample "Experiments") and
* holds at most 100 characters, and a hidden `datalist` of the existing names (the fixture's two) follows the field:
* the shared input dialog's suggestions list, as Set category's (#332 D140).
*/
function mapAddDialogConcept(reference) {
const [cancel,apply]=reference.actions.buttons;
const [field,...rest]=reference.tree;
return {...reference,tree:[field,'datalist',...rest],
input:{...reference.input,value:'',maxLength:100,list:'workspace-input-suggestions'},
suggestions:{id:'workspace-input-suggestions',display:'none',options:['Experiments','Landscapes']},
actions:{...reference.actions,buttons:[cancel,{...apply,text:'Add'}]}};
}
async function openAddDialog(page,main) {
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
await page.locator(`${main} #bulkbar`).getByRole('button',{name:'Add to collection',exact:true}).click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Add selected images to collection');
await page.mouse.move(0,0);
return page.evaluate(measureAddDialog);
}
async function conceptAddDialog(page,theme) {
await openConcept(page,'images',theme);
return openAddDialog(page,'#main');
}
async function productionAddDialog(page,theme) {
await openProduction(page,'images',theme);
await expect(page.locator(CARDS).first()).toBeVisible();
return openAddDialog(page,'#workspace-main');
}
test.beforeEach(async({page,request},testInfo)=>{testInfo.manifest=await seedConceptAt(page,request);});
for(const theme of THEMES)for(const [width,height] of SIZES) {
test(`add to collection dialog matches the concept ${theme} ${width}x${height}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const concept=await conceptAddDialog(page,theme);
await page.screenshot({path:testInfo.outputPath(`concept-add-to-collection-${theme}-${width}.png`)});
const production=await productionAddDialog(page,theme);
await page.screenshot({path:testInfo.outputPath(`production-add-to-collection-${theme}-${width}.png`)});
expect(concept.input.value,'the concept pre-fills its sample').toBe('Experiments');
expect(diffInventory(mapAddDialogConcept(concept),production)).toEqual([]);
});
}
test('add to collection comparison detects mutations',async({page})=>{
await page.setViewportSize({width:1600,height:1100});
const concept=await conceptAddDialog(page,'dark');
const expected=mapAddDialogConcept(concept);
const production=await productionAddDialog(page,'dark');
expect(diffInventory(expected,production)).toEqual([]);
// Unmapped, the concept differs only where D138 is recorded.
expect(diffInventory(concept,production).map(item=>item.path).sort()).toEqual(expect.arrayContaining(
['actions.buttons.1.text','tree.1','input.list','input.maxLength','input.value']));
const paths=async()=>diffInventory(expected,await page.evaluate(measureAddDialog)).map(item=>item.path);
await page.evaluate(()=>{document.querySelector('dialog#modal[open] label[for="action-input"]').textContent='Name';});
expect(await paths()).toEqual(['label.text']);
await page.evaluate(()=>{document.querySelector('dialog#modal[open] label[for="action-input"]').textContent='Collection name';});
const undoStyle=await mutate(page,{css:'dialog#modal #action-input { border-color: rgb(255, 0, 0); } dialog#modal .dialog-actions { gap: 31px; }'});
expect(await paths()).toEqual(expect.arrayContaining(['input.borderTopColor','actions.columnGap']));
await undoStyle();
await page.evaluate(()=>document.querySelector('#workspace-input-suggestions').remove());
expect(await paths()).toEqual(expect.arrayContaining(['suggestions']));
});
-62
View File
@@ -118,27 +118,9 @@ export async function openConcept(page,route,theme,motion='no-preference') {
await expect(page.locator('#main #page-title')).toBeVisible();
}
/**
* D157 (#451): production opens Images in Masonry when the browser has no Default layout; the concept starts at Grid.
* A comparison (and a test of the Grid's own geometry) starts production from the concept's state, a stored Default
* layout of Grid, once per tab and only while this browser stored nothing (a test's own settings win).
*/
export async function startInGrid(page) {
if (page.conceptLayoutSeeded) return;
page.conceptLayoutSeeded = true;
await page.addInitScript(() => {
try {
if (!location.pathname.startsWith('/prompt_manager/') || sessionStorage.getItem('pm-d157-seeded')) return;
sessionStorage.setItem('pm-d157-seeded', '1');
if (localStorage.getItem('gallerySettings') === null) localStorage.setItem('gallerySettings', JSON.stringify({ workspaceLayout: 'grid' }));
} catch { /* A page without storage keeps the production default. */ }
});
}
/** Production at `#/<route>` with the D4 Theme select, once the sidebar's real counts (D27) have settled. */
export async function openProduction(page,route,theme,motion='no-preference') {
await page.emulateMedia({reducedMotion:motion});
await startInGrid(page);
await page.goto('about:blank');await page.goto(`${PRODUCTION_URL}#/${route}`);
await selectTheme(page,theme);
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
@@ -232,41 +214,6 @@ export async function withChipSource(page,root,origin,category=null) {
};
}
/**
* #449 D154: the inspector's tag sections in the concept's prompt-section frame (workspace.css `.tag-section`). The concept
* has one bare `.tags` row after its prompt blocks (app.js a402), so the recorded rule is added and that row wrapped at
* runtime, the browser laying out what follows: `sections` lists `[className, label, name]` per section, the first
* holding the concept's row and each further one a copy of it. Returns the undo.
*/
export const D154_TAG_SECTION=`.tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; }
.tag-section .role-label { color: var(--ca-muted); }
.tag-section > .tags { margin-top: 0; }`;
export const PROMPT_TAGS=[['linked-tags','Prompt tags','Prompt tags']];
export async function withTagSections(page,root,sections=PROMPT_TAGS) {
const restoreRule=await mutate(page,{css:D154_TAG_SECTION});
const token=await page.evaluate(({root,sections})=>{
const container=document.querySelector(root);
const inner=container?.querySelector(':scope > .detail-inner')??container?.querySelector(':scope > div');
const row=inner&&[...inner.querySelectorAll(':scope > .tags')].find(node=>node.previousElementSibling?.matches('section.prompt-section'));
if(!row)throw new Error(`withTagSections: no tag row after the prompt blocks under ${root}`);
const anchor=row.nextSibling,parent=row.parentNode,added=[];
sections.forEach(([className,label,name],index)=>{
const section=document.createElement('section');section.className=`tag-section ${className}`;section.setAttribute('aria-label',name);
const heading=document.createElement('div');heading.className='prompt-heading';
const text=document.createElement('span');text.className='role-label';text.textContent=`${label} · ${row.children.length}`;
heading.append(text);section.append(heading,index?row.cloneNode(true):row);
parent.insertBefore(section,anchor);added.push(section);
});
const token=`tag-sections-${Math.random().toString(36).slice(2)}`;
(window.__tagSections??=new Map()).set(token,()=>{parent.insertBefore(row,added[0]);added.forEach(section=>section.remove());});
return token;
},{root,sections});
return async()=>{
await page.evaluate(token=>{window.__tagSections.get(token)();window.__tagSections.delete(token);},token);
await restoreRule();
};
}
/**
* Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library,
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
@@ -715,13 +662,6 @@ export function measureInspector(selector) {
text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text),
...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
}
function tagSectionOf(section) {
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label'),row=section.querySelector(':scope > .tags');
return {className:classes(section),name:section.getAttribute('aria-label'),...edges(section),...box(section),
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})),
label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})),
row:when(row,rowOf)};
}
function factsOf(list) {
const style=getComputedStyle(list);
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
@@ -755,8 +695,6 @@ export function measureInspector(selector) {
badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf),
sections:sections.map(sectionOf),
tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf),
// #449 D154: the labelled tag sections after the prompt blocks, each with its frame, heading, label and chip row.
tagSections:parts.filter(item=>item.matches('section.tag-section')).map(tagSectionOf),
facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf),
// Disclosures (viewer only) by their summary and frame; their content is the D20 production facts.
details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary');
+17 -67
View File
@@ -28,11 +28,6 @@ import weakref
from pathlib import Path
from typing import Any, Callable, TypeVar
try:
import fcntl
except ImportError: # Windows: no flock, and no /dev/shm to share
fcntl = None
T = TypeVar("T")
# Set to 1 to run seeders on the live database as before #384 (the equivalence
@@ -48,13 +43,8 @@ MEMORY_ROOT = Path("/dev/shm")
# the browsers and the other workers; with less free than this, the scratch
# copy goes on disk instead.
MEMORY_FREE_BYTES = 32 * 1024 * 1024
# Scratch folders are created as <prefix>new-<owner pid>-<random>, locked, then
# renamed to <prefix>held-<owner pid>-<random>. The pid is for people only:
# ownership is the flock on OWNER_LOCK, which every PID namespace sees (#419).
# Scratch folders are named <prefix><owner pid>-<random>.
SCRATCH_PREFIX = "pm-reset-"
STAGING_PREFIX = f"{SCRATCH_PREFIX}new-"
HELD_PREFIX = f"{SCRATCH_PREFIX}held-"
OWNER_LOCK = "owner.lock"
def _quote(name: str) -> str:
@@ -106,40 +96,29 @@ def table_digests(conn: sqlite3.Connection, schema: str = "main") -> dict[str, s
return result
def _hold(folder: Path, *, create: bool) -> int:
"""Take ``folder``'s owner lock without waiting; the open descriptor."""
flags = os.O_RDWR | (os.O_CREAT if create else 0)
fd = os.open(folder / OWNER_LOCK, flags, 0o600)
def _process_alive(pid: int) -> bool:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
os.kill(pid, 0)
except ProcessLookupError:
return False
except OSError:
os.close(fd)
raise
return fd
return True # it exists but belongs to someone else
return True
def remove_orphaned_scratch(root: Path) -> None:
"""Remove scratch folders of servers that died without cleaning up.
A server killed outright (SIGKILL, a crash) never runs ``close``, and
tmpfs keeps its folder in memory until reboot. A live owner holds the
flock on the folder's owner lock; the kernel drops it when the owner
dies, in whatever PID namespace it ran (a container with ``--ipc=host``
shares /dev/shm but not the pids, #419). A folder whose lock is free is
removed. Folders without a lock (staging, or named before #419) cannot
be proven orphaned and are left alone.
tmpfs keeps its folder in memory until reboot. The folder name carries
the owner's process id; a folder whose process is gone is removed.
"""
if fcntl is None:
return
for folder in root.glob(f"{HELD_PREFIX}*"):
try:
fd = _hold(folder, create=False)
except OSError:
continue # held by its owner, no lock to judge by, or gone
try:
if os.name != "posix":
return # os.kill(pid, 0) would terminate the process on Windows
for folder in root.glob(f"{SCRATCH_PREFIX}*-*"):
pid = folder.name[len(SCRATCH_PREFIX) :].split("-", 1)[0]
if pid.isdigit() and not _process_alive(int(pid)):
shutil.rmtree(folder, ignore_errors=True)
finally:
os.close(fd)
class _SeedApi:
@@ -183,7 +162,6 @@ class ScratchSeeder:
self._fallback = Path(directory)
self._memory_root = memory_root
self._directory: Path | None = None
self._owner_lock: int | None = None
self._on_live = on_live
self._scratch = None
self._unsynced: "weakref.WeakSet[sqlite3.Connection]" = weakref.WeakSet()
@@ -288,9 +266,6 @@ class ScratchSeeder:
if self._directory is not None:
shutil.rmtree(self._directory, ignore_errors=True)
self._directory = None
if self._owner_lock is not None:
os.close(self._owner_lock) # after the removal: never a free lock
self._owner_lock = None
def _scratch_directory(self) -> Path:
"""A folder in memory (tmpfs) where there is one, else ``directory``.
@@ -299,35 +274,10 @@ class ScratchSeeder:
kernel is writing back the pages they rewrite.
"""
root = self._memory_root
if (
fcntl is not None
and root is not None
and root.is_dir()
and os.access(root, os.W_OK)
):
if root is not None and root.is_dir() and os.access(root, os.W_OK):
remove_orphaned_scratch(root)
if shutil.disk_usage(root).free >= MEMORY_FREE_BYTES:
held = self._held_memory_directory(root)
if held is not None:
return held
prefix = f"{SCRATCH_PREFIX}{os.getpid()}-"
return Path(tempfile.mkdtemp(prefix=prefix, dir=root))
self._fallback.mkdir(parents=True, exist_ok=True)
return self._fallback
def _held_memory_directory(self, root: Path) -> Path | None:
"""A new folder under ``root`` that this seeder holds the lock of.
It is locked under the staging name, which no cleaner judges, and
only then renamed: no cleaner ever sees it held-named and unlocked.
"""
staging = Path(
tempfile.mkdtemp(prefix=f"{STAGING_PREFIX}{os.getpid()}-", dir=root)
)
try:
fd = _hold(staging, create=True)
except OSError:
shutil.rmtree(staging, ignore_errors=True)
return None # no locks on this filesystem: use the disk
held = root / (HELD_PREFIX + staging.name[len(STAGING_PREFIX) :])
os.rename(staging, held)
self._owner_lock = fd
return held
+24 -41
View File
@@ -130,7 +130,7 @@ test('panel matches the concept order and copy',async({page,request})=>{
// Linked, workflow inferred: every concept element.
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid']});
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid']});
await expect(panel.locator('img.detail-cover')).toHaveAttribute('alt','');
await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
@@ -139,21 +139,16 @@ test('panel matches the concept order and copy',async({page,request})=>{
// #332 MC8: the chip names where its value came from, in visible text after the category.
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection · recipe');
await expect(badges.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling); D154
// (#449) labels their own section after the negative prompt, named for the recipe as D146's category chip is.
const recipeTags=panel.getByRole('region',{name:'Recipe tags',exact:true});
await expect(recipeTags).toHaveClass('tag-section linked-tags');
await expect(recipeTags.locator('.prompt-heading .role-label')).toHaveText('Recipe tags · 2');
await expect(recipeTags.locator('.tags > span.chip')).toHaveText(['fixture','geometric']);
await expect(panel.locator('.tag-section button')).toHaveCount(0);
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling).
await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
const footer=panel.locator('.detail-footer');
// #331 D139: every listed file's footer ends with Collections… (folder icon).
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history']);
await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(2);
await expect(panel).not.toContainText(/prompt evidence|Positive prompt tags/);
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(1);
await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
// Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format.
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('explicit-empty.png');
@@ -162,30 +157,27 @@ test('panel matches the concept order and copy',async({page,request})=>{
await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive');
await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters');
await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension ');
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Collections…']);
// Unlinked: no badge, category, tags, pair or history; the footer holds only Collections… (D139).
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI']);
// Unlinked: no badge, category, tags, pair or history.
reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']);
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner'],
inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']});
await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
// Ambiguous saved association: the same honest absence.
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};};
await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png');
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.tag-section')).toHaveCount(0);
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
// D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree.
const tagRow=panel.locator('.tag-section.linked-tags > .tags');
const tagRow=panel.locator('.detail-inner > .tags').last();
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};};
await selectFile(page,'complete.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']);
await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Prompt tags · 2');
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};};
await selectFile(page,'workflow-only.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: ');
await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0);
await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
// Loading and error states render in `.detail-inner` as `.small` text with the existing buttons.
reads.patch=null;await page.unroute('**/images/inspect/**');
await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}}));
@@ -249,7 +241,7 @@ test('tag chip filters Images by that tag',async({page,request})=>{
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'workflow-only.png');
const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.has('tags'));
await panel.getByRole('region',{name:'Prompt tags',exact:true}).getByRole('button',{name:'geometric',exact:true}).click();
await panel.locator('.detail-inner > .tags').getByRole('button',{name:'geometric',exact:true}).click();
expect(new URL((await listing).url()).searchParams.get('tags')).toBe('geometric');
await expect(page.locator('article[data-image-key="0:inspector/workflow-only.png"]')).toBeVisible();
await expect(page.locator('article[data-image-key="0:inspector/complete.png"]')).toBeVisible();
@@ -266,7 +258,7 @@ test('pair send and history appear only with real roles and link',async({page,re
await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible();
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
await expect(panel.locator('.detail-footer')).toHaveCount(0);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
});
test('no provenance, raw or download controls in the side panel',async({page,request})=>{
@@ -294,19 +286,19 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
await fullFixture(page,request);let details=await viewerDetails(page,'complete.png');
// The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures,
// then the pair send and history buttons (D20) and D139's Collections….
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button','button'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid','button.settings-summary','details','details']});
// then the pair send and history buttons (D20).
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']});
await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary');
await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal');
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');
await expect(details.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']);
await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history']);
await expect(details.locator(':scope > button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
// File & provenance: the honest facts and the concept's "Prompt evidence" line; no per-fact source lines.
const provenance=details.locator('details').first();await provenance.locator('summary').click();
@@ -317,9 +309,7 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await expect(provenance.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
await expect(provenance.getByText('Saved association; not verified against current file',{exact:true})).toBeVisible();
await expect(provenance.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
// D154 (#449) labels the recipe's tags in their own section; the other legacy labels stay gone.
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension/);
await expect(details.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension|Recipe tags/);
// Workflow & raw metadata: the concept's focusable `pre`, then the downloads with their unavailable reasons.
const raw=details.locator('details').last();await raw.locator('summary').click();
await expect(raw.locator('pre[tabindex="0"] > code').first()).toBeVisible();
@@ -333,10 +323,9 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await expect(details.getByText('Prompt evidence: embedded parameters',{exact:true})).toBeVisible();
await expect(details.locator('.panel-heading .chip')).toHaveText('File extension PNG');
await expect(details.locator('details dl.meta-grid dd').nth(4)).toHaveText('PNG extension');await expect(details).not.toContainText('.PNG');
// No badge, no line; no resolved pair or link: after the details only D139's Collections….
// No badge, no line; no resolved pair or link, no buttons after the details.
reads.patch=null;await page.keyboard.press('Escape');details=await viewerDetails(page,'empty.png');
expect((await viewerStructure(details)).outer).toEqual(['div','button']);
await expect(details.locator(':scope > button')).toHaveText(['Collections…']);
expect((await viewerStructure(details)).outer).toEqual(['div']);
await details.getByText('File & provenance',{exact:true}).click();await expect(details).not.toContainText('Prompt evidence');
// The history button leaves the viewer for the prompt's history.
await page.keyboard.press('Escape');details=await viewerDetails(page,'complete.png');
@@ -475,7 +464,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page
test('embedded gallery uses the same full inspector',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
});
test('Prompt history to the shown route closes the viewer and re-renders it, as the concept go() does',async({page,request})=>{
@@ -558,8 +547,7 @@ test.describe('full inspector coarse pointer',()=>{
}
await openSelected(page);const viewer=page.locator('dialog#viewer-dialog');
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
// #450 D156: the view switch is hidden at 700px and below; a shown one is measured like ×.
for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button:visible').all()) {
for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button').all()) {
const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};});
expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80);
}
@@ -622,11 +610,6 @@ test('viewer matches the concept shell and toolbar order',async({page,request})=
const production=await page.evaluate(VIEWER_SHELL);
// C8 rebuilds the details content; until then the shared file inspector marks the aside it renders into.
production.paths=production.paths.map(path=>path.replace('aside#viewer-details.file-inspector','aside#viewer-details'));
// #450 D156: production's view switch sits right before × (hidden at 700px and below); the concept has none.
const modePaths=production.paths.filter(path=>path.includes('button#viewer-mode'));
expect(modePaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-mode.icon-btn','button#viewer-mode.icon-btn > svg.icon','button#viewer-mode.icon-btn > svg.icon > path']);
expect(production.paths[production.paths.indexOf(modePaths[2])+1]).toBe('dialog#viewer-dialog > div.dialog-header > button#viewer-close');
production.paths=production.paths.filter(path=>!modePaths.includes(path));
// #249 D34: after Next → production appends its own status lines (position, then the transform and slideshow
// reasons), and Download stays the original-file link; every other control keeps the concept's text and order.
const appended=production.toolbar.slice(9);
+5 -51
View File
@@ -30,12 +30,9 @@ function captionDay(seconds,now=new Date()) {
return `${name} · ${date.toLocaleDateString('en-US',options)}`;
}
/**
* D8 (amended by #249 PR D and #331): the selection bar's real actions only; Remove links… is left out. Add to
* collection compares in place (D138).
*/
const OMITTED_BULK=Object.freeze(['Remove links…']);
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
/** D8 (amended by #249 PR D): the selection bar's real actions only; Add to collection and Remove links… are left out. */
const OMITTED_BULK=Object.freeze(['Add to collection','Remove links…']);
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
/** D85: Auto-tag selected follows the concept's Add tags, a concept button of its own text. */
const AUTO_TAG_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-tags"]',text:'Auto-tag selected'}]);
@@ -92,8 +89,7 @@ export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...reference.toolbar};
// D153 (#447): production offers no "Highest rated".
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first','Name Z–A']}:item);
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
// D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
// COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
// D141 (#332): the list also holds the listed images' own categories (`GET /prompt_manager/images/categories`),
@@ -468,7 +464,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
'Add to collection','Export metadata','Remove links…','Clear']);
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected',
'Set category','Add to collection','Export metadata','Clear']);
'Set category','Export metadata','Clear']);
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
// The bar comparison notices a change to the production bar.
@@ -476,48 +472,6 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
});
/**
* #331 D137: the collection page's Edit leads the heading actions, drawn in the concept as a plain copy of its Import
* button reading "Edit" (no icon, no action), so the browser lays out what follows it; returns an undo.
*/
async function drawCollectionEdit(page) {
await page.evaluate(()=>{
const actions=document.querySelector('#main .heading-actions');
const edit=actions.querySelector('button').cloneNode(false);
for(const key of Object.keys(edit.dataset))delete edit.dataset[key];
edit.textContent='Edit';actions.prepend(edit);window.__collectionEdit=edit;
});
await settle(page);
return ()=>page.evaluate(()=>{window.__collectionEdit.remove();delete window.__collectionEdit;});
}
// #331 D137: `#/collection/<id>` against the concept's `#collection-landscapes` (app.js a1152–1157, a1186–1189): the
// collection's name as the h1, "Collection" as the breadcrumb, the Images description, toolbar and filter row. The grid
// is not compared: the concept's members come from its categories, production's from the stored collection.
for(const [width,height] of SIZES)for(const theme of THEMES)test(`collection page heading, toolbar and filter row match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const manifest=testInfo.manifest;
await openConcept(page,'collection-landscapes',theme);await page.mouse.move(0,0);await settle(page);
await expect(page.locator('#main #page-title')).toHaveText('Landscapes');
await expect(page.locator('#breadcrumb')).toHaveText('Collection');
const restore=await drawCollectionEdit(page);
const reference=await inventoryOf(page,'#main');
await restore();
await openProduction(page,`collection/${manifest.collections.Landscapes}`,theme);await page.mouse.move(0,0);
await expect(page.locator(CARDS)).toHaveCount(12);
await expect(page.locator('#workspace-main #page-title')).toHaveText('Landscapes');
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Collection');
await expect(page).toHaveTitle('Collection · Prompt Manager');
await settle(page);
const actual=await inventoryOf(page,'#workspace-main');
expect.soft(actual.heading.actions.buttons.map(item=>item.text),'production heading actions').toEqual(['Edit','Import','New prompt']);
expect.soft(reference.heading.actions.buttons.map(item=>item.text),'mapped concept heading actions').toEqual(['Edit','Import','New prompt']);
expect.soft(compare(reference,actual,manifest,['heading','toolbar','filterRow']),'collection page').toEqual([]);
// The comparison notices a lost Edit.
await mutate(page,{remove:['#workspace-main .heading-actions > button:first-child']});
expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['heading']).length).toBeGreaterThan(0);
});
/** #332: an own category on one listed image, set through the real route (PUT /prompt_manager/images/category). */
const IMAGE_CATEGORY='Portraits';
async function setImageCategory(request,path,category) {
+10 -17
View File
@@ -9,7 +9,7 @@ import { parityPng } from './local-inspect-files.mjs';
// `#metadata` (docs/concepts/workspace/app.js renderMetadata 740–748, selection 1715–1729, style.css 709–722,
// 1180–1194, 1543–1545, 1559–1564), at 1600x1100 and 390x844 in both themes, in three states: initial, selected (the
// same synthetic PNG set into both `#local-file` inputs) and viewer-open. The concept is never edited: the recorded
// differences D60–D66 and D155 (TESTING.md) are applied to its measurement, and, where they change copy, to its page before it
// differences D60–D66 (TESTING.md) are applied to its measurement, and, where they change copy, to its page before it
// is measured, so the browser lays the concept out with production's words. After that the diff must be empty.
// Paired captures are artifacts only; there are no PNG baselines.
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
@@ -22,8 +22,7 @@ const COPY = {
'Read embedded metadata in this browser. The file is not uploaded or added to your library.'],
chooser: ['Choose an image for a local preview', 'Choose an image or video for a local preview'],
note: ['This mock previews your file locally. Sample metadata is available with “Open sample metadata”.',
// D155 (#448): choosing the file opens its metadata.
'Your file stays in this browser. Choosing it opens its metadata.'],
'Your file stays in this browser. Its metadata opens with “Open file metadata”.'],
toast: ['Local preview only. No file uploaded; sample metadata is available above.',
'Local preview only. No file uploaded; metadata is available above.'],
};
@@ -213,29 +212,23 @@ async function productionStates(page, theme, testInfo, tag) {
await settle(page);
const initial = await page.evaluate(measureInspect, '#workspace-main');
await captureCanonicalState(page, testInfo, `production-initial-${tag}`);
// D155 (#448): choosing the file opens the viewer with its details at once (the concept waits for its heading
// action), so the viewer state is measured first; the selected page is measured after Escape, with the focus the
// close returned to the chooser removed (the concept's page has no focused control there).
await page.locator('#workspace-main #local-file').setInputFiles(parityPng(FILE));
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .panel-heading')).toBeVisible();
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .meta-grid').first()).toBeVisible();
await page.mouse.move(0, 0);
await settle(page);
const viewer = await page.evaluate(measureViewer);
await captureCanonicalState(page, testInfo, `production-viewer-${tag}`);
await page.keyboard.press('Escape');
await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
await page.evaluate(() => document.activeElement?.blur());
await expect(page.locator('#workspace-main #local-preview img.local-file-preview')).toBeVisible();
await page.locator('#workspace-main #local-preview img').evaluate(image => image.decode().catch(() => {}));
// D155: no toast; the opened viewer was the confirmation.
await expect(page.locator('#workspace-toast')).toHaveText('');
await expect(page.locator('#workspace-toast')).toHaveText(COPY.toast[1]);
await expect(action).toHaveAttribute('aria-disabled', 'false');
// D63: a readable PNG shows no status line.
await expect(page.locator('#workspace-main #local-preview p.small')).toHaveCount(0);
await settle(page);
const selected = await page.evaluate(measureInspect, '#workspace-main');
await captureCanonicalState(page, testInfo, `production-selected-${tag}`);
await action.click();
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .panel-heading')).toBeVisible();
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .meta-grid').first()).toBeVisible();
await page.mouse.move(0, 0);
await settle(page);
const viewer = await page.evaluate(measureViewer);
await captureCanonicalState(page, testInfo, `production-viewer-${tag}`);
return { initial, selected, viewer };
}
+29 -81
View File
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource,withTagSections,PROMPT_TAGS} from './concept-inventory.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource} from './concept-inventory.mjs';
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
@@ -51,12 +51,10 @@ function withCategoryScope(inventory) {
}
/**
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139, D146 and D154, whose labelled
* tag sections the concept page gets at runtime from `withTagSections`); everything else must
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117 and D146); everything else must
* equal production. `reference.viewerPlain` is the concept's own full details
* with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's
* "Collections…" is drawn after Prompt history in both the concept's panel footer and its full details.
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do.
*/
export function mapInspectorConcept(reference,{viewport}) {
const {panel,viewerPlain,fileControls}=reference;
@@ -109,36 +107,18 @@ async function openProductionPanel(page,theme) {
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
await expect(panelOf(page).locator('.detail-footer')).toBeVisible();
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(1);
await expect(panelOf(page).locator('.detail-inner > .tag-section.linked-tags')).toBeVisible();
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(2);
await settle(page);
}
/**
* #331 D139: production's "Collections…" after Prompt history in `selector`'s buttons, drawn in the concept as its own
* Prompt history button (no action) with the concept's folder icon (its Images filter chip's); returns an undo.
* The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy
* settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON",
* without its action); returns an undo.
*/
async function withCollectionsButton(page,selector) {
await page.evaluate(selector=>{
const history=[...document.querySelectorAll(`${selector} > button`)].find(button=>button.textContent.includes('Prompt history'));
const button=history.cloneNode(false);
for(const key of Object.keys(button.dataset))delete button.dataset[key];
button.append(document.querySelector('.filter-row .chip > svg.icon').cloneNode(true),' Collections…');
history.after(button);(window.__collectionsButtons??=[]).push(button);
},selector);
await settle(page);
return async()=>{await page.evaluate(()=>{for(const button of window.__collectionsButtons??[])button.remove();delete window.__collectionsButtons;});};
}
/**
* The concept's tag chips in the open viewer in D154's labelled sections (`sections`) as plain `span.chip`s (D20), D34's
* coarse targets and D117's "Copy settings summary" after the six facts (LI-4), drawn as the concept's own plain button
* (its "Download sample JSON", without its action); returns an undo.
*/
async function plainViewerChips(page,sections) {
async function plainViewerChips(page) {
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
const restoreSource=await withChipSource(page,'#viewer-details',ORIGIN);
const restoreSections=await withTagSections(page,'#viewer-details',sections);
await page.evaluate(()=>{
window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
@@ -149,12 +129,11 @@ async function plainViewerChips(page,sections) {
summary.textContent='Copy settings summary';summary.classList.add('settings-summary');
document.querySelector('#viewer-details > div > dl.meta-grid').after(summary);window.__summaryButton=summary;
});
const restoreCollections=await withCollectionsButton(page,'#viewer-details');
await settle(page);
return async()=>{
await restoreCollections();
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;
window.__summaryButton.remove();delete window.__summaryButton;});
await restoreSections();await restoreSource();await restoreTargets();
await restoreSource();await restoreTargets();
};
}
@@ -172,7 +151,7 @@ async function fileControlsOf(page) {
* The first card's viewer: its full details and open "File & provenance" measured (in the concept also with plain
* chips), then closed again. `mutation` (production) is applied once the viewer is open and undone before it closes.
*/
async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) {
async function viewerOf(page,concept,mutation=null) {
await page.locator(CARDS).first().locator('.image-open').click();
await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE);
await settle(page);
@@ -181,7 +160,7 @@ async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) {
const {viewerDetails}=await inspectorOf(page);
const fileControls=await fileControlsOf(page);
let viewerPlain=null;
if(concept) {const restore=await plainViewerChips(page,sections);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
await restoreMutation?.();
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
@@ -189,18 +168,13 @@ async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) {
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page);
return {viewerDetails,viewerPlain,fileControls};
}
async function inventory(page,concept=false,sections=PROMPT_TAGS) {
// D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history; D154's
// labelled tag sections (by default the fixture's D39 positive tags under "Prompt tags"), laid out by the concept page.
async function inventory(page,concept=false) {
// D146: the concept panel's category chip with its visible origin, laid out by the concept page itself.
const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null;
const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null;
const restoreSections=concept?await withTagSections(page,'#image-inspector',sections):null;
if(concept)await settle(page);
const {panel}=await inspectorOf(page);
await restoreSections?.();
await restoreCollections?.();
await restoreSource?.();
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept,null,sections);
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
}
@@ -264,8 +238,7 @@ async function productionSendOf(page,context) {
/** A tag chip: the collection it leaves (the count line), then the chip pressed again for the same result. */
async function tagOf(page) {
const before=await page.locator('#result-count').textContent();
// The concept's bare row, or production's linked-tags section (D154): the last chip row either way.
await panelOf(page).locator('.detail-inner .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
// Production's caption reads "Loading images…" until the tag's listing answers (#391); the concept's search is instant.
await expect.poll(async()=>{const text=await page.locator('#result-count').textContent();return text!==before&&text!=='Loading images…';}).toBe(true);
await settle(page);
@@ -330,9 +303,6 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes · prompt']);
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
// D154: both documents measured the labelled section, so the comparison covers it (never two absent sections).
for(const [name,inventory] of [['concept panel',reference.panel],['concept viewer',reference.viewerPlain],['panel',actual.panel],['viewer',actual.viewerDetails]])
expect.soft(inventory.tagSections.map(section=>section.label.text),`${name} tag sections`).toEqual(['Prompt tags · 3']);
expect.soft(diff,'differences after the recorded deviations').toEqual([]);
// Copy: the concept's toast "Prompt copied." (or its dialog), in both documents.
expect.soft(production.copy,'copy').toEqual(concept.copy);
@@ -362,18 +332,15 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
await openProductionPanel(page,'dark');
await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
const badges=panelOf(page).locator('.detail-inner > .tags');
await expect(badges).toHaveCount(1);
await expect(badges.locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
await expect(badges.locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
const rows=panelOf(page).locator('.detail-inner > .tags');
await expect(rows).toHaveCount(2);
await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
// D146 (#332): the visible origin names where the value came from.
await expect(badges.locator('.chip .chip-source')).toHaveText(' · prompt');
// D154: the positive's tags, so the section is "Prompt tags", not "Recipe tags".
const tags=panelOf(page).getByRole('region',{name:'Prompt tags',exact:true});
await expect(tags.locator('.role-label')).toHaveText('Prompt tags · 3');
await expect(tags.locator('.tags > button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(tags.locator('span.chip')).toHaveCount(0);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt');
await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
});
// #391: a tag chip replaces the collection; until its listing answers the caption says it is loading, never that the
@@ -386,7 +353,7 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
await page.route(url=>url.pathname==='/prompt_manager/images/output'&&url.searchParams.get('tags')==='cinematic',
async route=>{arrived();await held;await route.continue();},{times:1});
const count=page.locator('#result-count');
await panelOf(page).locator('.tag-section.linked-tags').getByRole('button',{name:'cinematic',exact:true}).click();
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
await reached;
await expect(count).toHaveText('Loading images…');
await expect(page.locator(CARDS)).toHaveCount(0);
@@ -396,9 +363,8 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
});
// Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names
// saved as the file's current image tags, production draws them in D154's "Image tags" section (Tags filter buttons in
// the panel, plain in the viewer) and the linked positive's equal tags in its "Prompt tags" section after it (#449),
// and equals the concept with its row in those two sections, in both sizes.
// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain
// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes.
for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{
test.setTimeout(120_000);
const viewport={width,height};await page.setViewportSize(viewport);
@@ -406,14 +372,13 @@ for(const [width,height] of SIZES)test(`image tags saved on the concept image ke
const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents();
expect(names).toEqual(['cinematic','blue_hour','architecture']);
await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector');
const reference=await inventory(page,true,[['image-tags','Image tags','Image tags'],...PROMPT_TAGS]);
const reference=await inventory(page,true);
const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png');
expect(await saveImageTags(request,path,names)).toEqual(names);
const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`);
await openProductionPanel(page,'dark');
expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names});
await expect(panelOf(page).locator('.tag-section.image-tags .chip')).toHaveText(names);
await expect(panelOf(page).locator('.tag-section.linked-tags .chip')).toHaveText(names);
await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names);
await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector');
assertInspectorMatch(reference,await inventory(page),viewport);
});
@@ -438,17 +403,6 @@ test('inspector comparison detects mutations',async({page})=>{
await restoreCss();await settle(page);
assertInspectorMatch(reference,await measure(),viewport);
// D154: the tag section's muted label and its row's spacing are part of the mapped concept.
for(const [css,path] of [['#image-inspector .tag-section .role-label { color: rgb(255, 0, 0) }',/panel\.tagSections\.0\.label\.color/],
['#image-inspector .tag-section > .tags { margin-top: 12px }',/panel\.tagSections\.0\.row\.marginTop/],
['#image-inspector .tag-section { border-top-width: 0 }',/panel\.tagSections\.0\.borderTopWidth/]]) {
const restore=await mutate(page,{css});
const mutated=await measure();
expect(()=>assertInspectorMatch(reference,mutated,viewport)).toThrow(path);
await restore();await settle(page);
}
assertInspectorMatch(reference,await measure(),viewport);
const panel=(await inspectorOf(page)).panel;
const viewerMutated=async mutation=>{const {viewerDetails,fileControls}=await viewerOf(page,false,mutation);return {panel,viewerDetails,fileControls};};
const withoutActions=await viewerMutated({remove:['#viewer-details > button']});
@@ -460,12 +414,6 @@ test('inspector comparison detects mutations',async({page})=>{
expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/);
assertInspectorMatch(reference,await viewerMutated(),viewport);
// D139: Collections… is part of the mapped concept, so losing it is a difference.
const restoreButton=await mutate(page,{remove:['#image-inspector > .detail-footer > button:last-child']});
const withoutCollections=await measure();
expect(()=>assertInspectorMatch(reference,withoutCollections,viewport)).toThrow(/panel\.footer/);
await restoreButton();await settle(page);
// D146: the visible origin is part of the mapped concept, so losing it is a difference.
const restoreSource=await mutate(page,{remove:['#image-inspector .chip-source']});
const sourceMutated=await measure();
+8 -72
View File
@@ -21,8 +21,8 @@ export function mapLibraryConcept(reference) {
// Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...library.toolbar};
toolbar.items=toolbar.items.map(item=>{
// D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated".
if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first']};
// D14: the Library appends its Oldest first after the concept's three sorts.
if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']};
// D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order.
if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]};
return item;
@@ -30,20 +30,17 @@ export function mapLibraryConcept(reference) {
// A shorter toolbar moves what follows it up by as much.
const rise=library.toolbar.height-toolbar.height;
const cards=library.cards.map((card,index)=>({...card,
// D148 (#437): each card leads with the concept's Images `.image-overlays` selection checkbox, placed absolutely
// over the art, so nothing else moves; it is compared with the concept's own overlay below.
children:['image-overlays',...card.children],
// D23: "− N negative variants" uses the singular for one; that row is the concept's own, measured so (`oracle`).
meta:card.meta.items[0]?.text===PLURAL_ONE?reference.oracle.meta[index]:card.meta,
// D23: a long title wraps inside the title row, so the title button may shrink below its text (`min-width: 0`).
title:{...card.title,items:card.title.items.map(item=>item.tag==='button'?{...item,minWidth:'0px'}:item)},
// D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
tags:sortedTags(card.tags),
// D153 (#447): no rating stars; the concept page itself is measured without them (`dropConceptRatings`). The
// fixture's run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own
// "4 runs" and "Used today". The two-line clamp is
// D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's
// run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own "4 runs"
// and "Used today". The two-line clamp is
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
footer:card.footer}));
footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}}));
return {...reference,library:{...library,toolbar,
// D35: the caption uses the singular for one.
caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))},
@@ -139,20 +136,9 @@ async function sortConceptTags(page) {
return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;});
}
/**
* D153 (#447): production draws no 1–5 rating. The concept's card footers read "★★★★★ · Used today"; the stars and
* their separator are taken out of the concept page itself, so the footer facts are measured as production draws them.
* Returns the concept's original footer texts.
*/
async function dropConceptRatings(page) {
return page.evaluate(()=>[...document.querySelectorAll('.prompt-card .card-footer > span')].map(span=>{
const original=span.textContent;span.textContent=original.replace(/^★+ · /,'');return original;}));
}
async function openConceptLibrary(page,theme,motion) {
await openConcept(page,'library',theme,motion);
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
const footers=await dropConceptRatings(page);
expect(footers,'the concept rates every card (D153 removes it)').toEqual(Array(CONCEPT_PROMPTS).fill().map((_,index)=>expect.stringMatching(/^★+ · \S/)));
await page.mouse.move(0,0);await decoded(page);await settle(page);
}
async function openProductionLibrary(page,theme,motion) {
@@ -306,7 +292,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
expect.soft(card.meta,`${motion} concept meta`).toMatchObject({fontSize:'10px',columnGap:'9px'});
expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']);
expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'});
expect.soft(card.footer.facts.text,`${motion} concept footer facts (D153: without the stars)`).toBe('Used today');
expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today');
expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]);
expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]);
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
@@ -325,56 +311,6 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
});
/**
* D148 (#437): the selection checkbox overlay of `card` and the `.bulkbar` row inside `main`, with one item checked,
* measured alike in the concept's Images page and production's Library: the overlay's inset in its card, the checkbox,
* and the bar's box, tokens and the actions both bars have (Add tags, Set category, Clear; #442).
*/
const measureSelection=(page,{card,main})=>page.evaluate(({card,main})=>{
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
const rect=node=>node.getBoundingClientRect(),px=value=>Math.round(value*100)/100;
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','color');
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginBottom','borderTopWidth','borderTopStyle',
'borderTopColor','borderTopLeftRadius','backgroundColor');
const host=document.querySelector(card),overlay=host.querySelector('.image-overlays'),label=overlay.querySelector(':scope > label');
const input=label.querySelector('input'),root=document.querySelector(main),bar=root.querySelector('.bulkbar');
const shared=['Add tags','Set category','Clear'];
return {
overlay:{left:px(rect(overlay).left-rect(host).left),top:px(rect(overlay).top-rect(host).top),right:px(rect(host).right-rect(overlay).right),
...css(overlay,'position','display','justifyContent','pointerEvents')},
label:css(label,'pointerEvents'),checkbox:{width:px(rect(input).width),height:px(rect(input).height),checked:input.checked,...css(input,'accentColor')},
bar:{left:px(rect(bar).left-rect(root).left),...css(bar,'display','alignItems','columnGap','rowGap','flexWrap'),...edges(bar),...font(bar),
count:{text:bar.querySelector(':scope > strong').textContent,...font(bar.querySelector(':scope > strong'))},
buttons:[...bar.querySelectorAll(':scope > button')].filter(button=>shared.includes(button.textContent.trim()))
.map(button=>({text:button.textContent.trim(),height:px(rect(button).height),...font(button),...edges(button)}))},
};
},{card,main});
async function conceptSelection(page,theme) {
await openConcept(page,'images',theme);
await page.locator('#image-grid .media-card').first().locator('input[data-select]').check();
await expect(page.locator('#bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0,0);await settle(page);
return measureSelection(page,{card:'#image-grid .media-card',main:'#main'});
}
async function productionSelection(page,theme) {
await openProductionLibrary(page,theme,'no-preference');
await page.locator(CARDS).first().locator('.image-overlays input').check();
await expect(page.locator('#library-bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0,0);await settle(page);
return measureSelection(page,{card:CARDS,main:'#workspace-main'});
}
for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library selection checkbox and bar match the concept's Images ones ${width}x${height} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const reference=await conceptSelection(page,theme);
await captureCanonicalState(page,testInfo,`concept-images-selection-${width}-${theme}`);
const actual=await productionSelection(page,theme);
await captureCanonicalState(page,testInfo,`production-library-selection-${width}-${theme}`);
await testInfo.attach('library-selection',{body:JSON.stringify({reference,actual},null,2),contentType:'application/json'});
expect(reference.bar.buttons.map(button=>button.text),'the concept bar has every Library action').toEqual(['Add tags','Set category','Clear']);
expect(actual).toEqual(reference);
// The comparison notices a moved checkbox (spec §7 mutation check).
await mutate(page,{css:'.prompt-card .image-overlays { inset: 20px 9px auto }'});
expect((await measureSelection(page,{card:CARDS,main:'#workspace-main'})).overlay.top).not.toBe(reference.overlay.top);
});
// The grid and cards at the concept's three-column breakpoint (style.css 1274–1276).
for(const theme of THEMES)test(`library grid and cards match the concept 1800x1100 ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width:1800,height:1100});
@@ -405,7 +341,7 @@ test('a run that crosses midnight still reads the concept fixture\'s day',async(
const reference=await conceptInventory(page);
await openProductionLibrary(page,'dark','no-preference');
const actual=await inventoryOf(page,'#workspace-main');
expect(actual.library.cards.map(card=>card.footer.facts.text)).toEqual(Array(CONCEPT_PROMPTS).fill('Used today'));
expect(actual.library.cards.map(card=>card.footer.facts.text.replace(/^★+ · /,''))).toEqual(Array(CONCEPT_PROMPTS).fill('Used today'));
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
});
@@ -25,10 +25,9 @@ export function inventoryOf(dialog) {
demo:Boolean(dialog.querySelector('.demo-note')),
};
}
// Documented differences only: prototype Save label, prototype demo note, data-dependent values, and D153 (#447): the
// concept's "Rating" select is not drawn.
// Documented differences only: prototype Save label, prototype demo note, and data-dependent values.
export function mapConcept(reference) {
return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
return {...reference,demo:false,actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
}
// Flatten an inventory to path->value so a comparison reports exactly which leaves differ.
-6
View File
@@ -1,6 +1,5 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import { startInGrid } from './concept-inventory.mjs';
const workspace='/prompt_manager/workspace#/images';
const details=page=>page.getByRole('complementary',{name:'Selected image details',exact:true});
@@ -39,9 +38,6 @@ async function chooseDestination(page) {
test('collection viewer rejects stale source and sends only refreshed API evidence',async({page,request,context})=>{
const host=await fakeHost(context,page);
// #430: the file is never found again here, so the manual Refresh is what this covers (the found-again path is
// workspace-stale-source.spec.mjs).
await page.route('**/prompt_manager/images/rescope',route=>route.fulfill({status:409,json:{success:false,code:'source-changed',error:'The source of this image changed or was removed.'}}));
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>window.copied.push(text)}});});
try {
const loaded=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const stale=loaded.images.find(image=>image.root_index===0&&image.path==='shared/same.png');
@@ -253,8 +249,6 @@ test('responsive gallery defers observed size writes and still fits the new view
});}
};
});
// The Grid's observed sizes (D157: the default layout is Masonry).
await startInGrid(page);
await page.setViewportSize({width:1280,height:900});await page.goto(workspace);
await expect(page.locator('.media-card')).toHaveCount(4);
await page.setViewportSize({width:390,height:844});
+1 -6
View File
@@ -46,9 +46,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.source_change_fixture import register_source_change_commands
from tests.browser.workspace_review_fixture import register_review_commands
from tests.browser.workspace_collections_fixture import register_collections_commands
from tests.browser.workspace_diagnostics_fixture import (
register_diagnostics_commands,
repoint_logs,
@@ -232,10 +230,8 @@ def create_app(root: Path) -> web.Application:
history_commands = register_workspace_history_commands(routes, api, root)
library_editor_commands = register_library_editor_commands(routes, api, root)
register_vocabulary_commands(routes, api, root)
restore_sources = register_source_change_commands(routes, api)
register_tagging_commands(routes, api, root, model, on_reset=restore_sources)
register_tagging_commands(routes, api, root, model)
register_review_commands(routes, api, root)
register_collections_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)
@@ -441,7 +437,6 @@ def create_app(root: Path) -> web.Application:
async def reset(request: web.Request) -> web.Response:
nonlocal history_fixture
request_holds.release()
restore_sources()
# No preview queued by the last test may land in the new fixture.
await api._run_in_executor(api.close_auto_thumbnails)
library_editor_commands.reset()
+7 -35
View File
@@ -76,41 +76,13 @@ function savedNav(nav) {
return {...nav,rows:nav.rows.map(row=>row.text==='Needs review'?{...row,count:{...row.count,text:'0'}}:row)};
}
/**
* D136 (#331): the concept's COLLECTIONS rows (app.js a336–339) as production lists them, by name (DC1): the fixture's
* "Landscapes" and "Experiments" hold the concept's 12 and 8 images, so text and counts compare as they are and each
* place keeps the concept's row geometry. At <=950px the concept hides the nav, which would leave a phone no way to
* open a collection: production shows it as the concept lays out every other sidebar nav there (its SAVED VIEWS nav),
* its rows styled as that nav's rows. Text, widths and wrap gaps then follow the rows' own content.
*/
function collectionsNav(nav,navs) {
const byName=[...nav.rows].sort((a,b)=>a.text<b.text?-1:a.text>b.text?1:0);
if(nav.shown)return {...nav,rows:byName.map((row,index)=>({...row,gapAfter:nav.rows[index].gapAfter}))};
const saved=navs.find(item=>item.id==='saved-nav');
const {id,label,rows,...frame}=saved;
return {...nav,...frame,id:nav.id,label:nav.label,rows:byName.map(row=>({...rows[0],text:row.text,
count:row.count&&rows[0].count?{...rows[0].count,text:row.count.text}:rows[0].count,
icon:rows[0].icon&&row.icon?{...rows[0].icon,d:row.icon.d}:row.icon,
width:'(content)',hugs:'(content)',gapAfter:'(content)',current:null}))};
}
/** The production collections nav at <=950px with its content-sized values replaced, as collectionsNav maps them. */
function contentFree(navs) {
return navs.map(nav=>nav.id!=='collections-nav'||!nav.shown?nav:{...nav,
rows:nav.rows.map(row=>({...row,width:'(content)',hugs:'(content)',gapAfter:'(content)'}))});
}
/** D136: where the concept hides COLLECTIONS, the production rows' content-sized values are not compared. */
function comparableNavs(reference,navs) {
return reference.chrome.navs.find(nav=>nav.id==='collections-nav')?.shown?navs:contentFree(navs);
}
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
function mapShellConcept(reference) {
const {chrome}=reference;
const navs=chrome.navs
// D28: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered.
.filter(nav=>nav.id!=='collections-nav')
.map(nav=>{
if(nav.id==='collections-nav')return collectionsNav(nav,chrome.navs);
if(nav.id==='saved-nav')return savedNav(nav);
if(nav.id!=='system-nav')return nav;
const system=rowNamed(nav,'System');
@@ -127,8 +99,9 @@ function mapShellConcept(reference) {
return {...reference,chrome:{...chrome,
// D29: no prototype suffix in the document title.
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
// D49: SAVED VIEWS without its "PROPOSED" badge. D136: COLLECTIONS without it either.
labels:chrome.labels.map(label=>label.text.endsWith(' PROPOSED')?{...label,text:label.text.replace(/ PROPOSED$/,'')}:label),
// D28: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge.
labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED')
.map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label),
navs,
connection:{...chrome.connection,
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
@@ -161,9 +134,8 @@ function headingFrame(heading) {
function compare(route,reference,actual) {
const expected=mapShellConcept(reference);
const heading=FULL_HEADING.has(route);
const chrome={...actual.chrome,navs:comparableNavs(reference,actual.chrome.navs)};
return [
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome,noPageScroll:actual.noPageScroll}),
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome:actual.chrome,noPageScroll:actual.noPageScroll}),
...diffInventory(heading?expected.heading:headingFrame(expected.heading),heading?actual.heading:headingFrame(actual.heading))
.map(item=>({...item,path:`heading.${item.path}`})),
];
@@ -270,7 +242,7 @@ for(const [width,height] of SIZES)test(`a saved view row matches the concept's $
expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']);
await openProduction(page,'images','dark');
const actual=await save('Save','#workspace-main');
expect(diffInventory(mapShellConcept(reference).chrome.navs,comparableNavs(reference,actual.chrome.navs))).toEqual([]);
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',referenceRows[0].count],['Needs review',{...referenceRows[1].count,text:'0'}],['Golden hour',null]]);
-66
View File
@@ -1,66 +0,0 @@
"""Source changes under an open workspace page (#430), undone by every reset.
- ``POST /__test__/sources/reverse``: the image sources are listed in reverse
order, as System › Sources would renumber them, so every held
``root_scope`` names another index and the page must re-scope its file.
- ``POST /__test__/scope-key/rotate``: the API's scope and cursor keys
change, as when ``workspace-scope.key`` is lost before a restart: a held
scope then cannot be mapped and the page offers Refresh.
"""
from __future__ import annotations
import secrets
from typing import TYPE_CHECKING, Callable
from aiohttp import web
from utils.workspace_keys import WorkspaceKeys
if TYPE_CHECKING:
from py.api import PromptManagerAPI
_CACHE = "_workspace_keys_cache"
def register_source_change_commands(
routes: web.RouteTableDef, api: PromptManagerAPI
) -> Callable[[], None]:
"""Register the commands; returns the restore every reset runs."""
# Imported here: py.config needs the host stubs create_app installs first.
from py.config import GalleryConfig
saved: dict = {}
def restore() -> None:
if "roots" in saved:
GalleryConfig.MONITORING_DIRECTORIES = saved.pop("roots")
api.invalidate_gallery_cache(retire=True)
if "keys" in saved:
keys = saved.pop("keys")
if keys is None:
api.__dict__.pop(_CACHE, None)
else:
api.__dict__[_CACHE] = keys
@routes.post("/__test__/sources/reverse")
async def reverse(request: web.Request) -> web.Response:
if request.query or request.can_read_body:
raise web.HTTPBadRequest()
roots = list(GalleryConfig.MONITORING_DIRECTORIES)
saved.setdefault("roots", roots)
GalleryConfig.MONITORING_DIRECTORIES = roots[::-1]
await api._run_in_executor(api.invalidate_gallery_cache, True)
return web.json_response({"success": True})
@routes.post("/__test__/scope-key/rotate")
async def rotate(request: web.Request) -> web.Response:
if request.query or request.can_read_body:
raise web.HTTPBadRequest()
saved.setdefault("keys", api.__dict__.get(_CACHE))
api.__dict__[_CACHE] = WorkspaceKeys(
secrets.token_bytes(32), secrets.token_bytes(32), False
)
return web.json_response({"success": True})
return restore
+12 -16
View File
@@ -162,6 +162,7 @@ async function serverJson(request, url) {
}
const number = value => value.toLocaleString('en-US');
const ratingText = value => value === null ? 'Not rated' : `${number(value)} / 5`;
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
function mapOverviewConcept(reference, overview) {
@@ -177,8 +178,8 @@ function mapOverviewConcept(reference, overview) {
'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }),
// D93: the real sources, not samples.
'2 sample output sources': row => ({ ...row, title: `${number(overview.sources.effective)} output sources` }),
// D96: the dialog shows runs by category and library totals, not prompt, tag or date usage (D153: no ratings).
Statistics: row => ({ ...row, description: 'Runs by category, with library totals.' }),
// D96: the dialog shows runs and ratings by category and library totals, not prompt, tag or date usage.
Statistics: row => ({ ...row, description: 'Runs and ratings by category, with library totals.' }),
};
const surfaces = reference.columns.surfaces.map(surface => ({ ...surface,
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) }));
@@ -287,10 +288,7 @@ test('system comparison detects mutations', async ({ page, request }) => {
expect(diffInventory(expected, await measure())).toEqual([]);
});
/**
* D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. D153 (#447): the
* concept's "Avg. rating" column and a "Rated images" total are not drawn.
*/
/** D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. */
function mapStatisticsConcept(reference, statistics) {
const [runs] = reference.tables;
return {
@@ -298,11 +296,10 @@ function mapStatisticsConcept(reference, statistics) {
// No sample note (prototype copy); a second table follows the first.
body: ['table', 'table'],
tables: [
{ ...runs, head: runs.head.filter(([, , text]) => text !== 'Avg. rating'),
rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]) },
{ ...runs, rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs), ratingText(row.average_rating)]) },
{ caption: 'Library totals', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
rows: [['Prompts', number(statistics.totals.total_prompts)], ['Tags', number(statistics.totals.total_tags)],
['Images', number(statistics.totals.total_images)]] },
['Images', number(statistics.totals.total_images)], ['Rated images', number(statistics.rated_images)]] },
],
};
}
@@ -475,9 +472,9 @@ function measureAppearance(mainSelector) {
}
// D97: the rows Appearance adds for the legacy gallery settings, after the concept's own, as production renders them.
// D156 (#450) moved "Collapse viewer details by default" to the Viewer surface (workspace-viewer-mode.spec.mjs).
const ADDED_APPEARANCE_ROWS = [
['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', true],
['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', false],
];
/** The concept's Appearance with the recorded deviations applied, given the server's real settings. */
@@ -488,11 +485,10 @@ function mapAppearanceConcept(reference, settings) {
control: { ...toggleRow.control, id, checked } }));
return {
...reference,
// D156: the Viewer surface, then (#317) the server's thumbnail settings in their own surface ("Thumbnails",
// README: retained in Appearance).
layout: [reference.layout[0], 'section.surface', 'section.surface', ...reference.layout.slice(1)],
// D97: one added toggle (D156 moved the other) and the "Reset display defaults" operation close the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.operation'] },
// #317: the server's thumbnail settings follow in their own surface ("Thumbnails", README: retained in Appearance).
layout: [reference.layout[0], 'section.surface', ...reference.layout.slice(1)],
// D97: two added toggles and the "Reset display defaults" operation close the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.setting-row', 'div.operation'] },
rows: [...reference.rows.map(row => row.control?.id === 'timeout'
// The node search timeout is the server's real `result_timeout`, not the concept's sample 30.
? { ...row, control: { ...row.control, value: String(settings.result_timeout) } } : row), ...added],
@@ -539,7 +535,7 @@ test('appearance comparison detects mutations', async ({ page, request }) => {
// Unmapped, the concept differs only where D97 and the real timeout are recorded.
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
.filter((path, index, all) => all.indexOf(path) === index).sort())
.toEqual(['layout.1', 'layout.2', 'layout.3', 'layout.length', 'operations.0', 'operations.length', 'rows.11', 'rows.12',
.toEqual(['layout.1', 'layout.2', 'layout.length', 'operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.13',
'rows.length', 'surface.tree'].sort());
const measure = () => page.evaluate(measureAppearance, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
+2 -5
View File
@@ -231,8 +231,6 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: ALPINE } })).toBeOK();
const save = () => tagDialog(page).locator('.dialog-actions .ca-primary').click();
const status = () => tagDialog(page).locator('#tag-generation-status');
// D147: a state that blocks Save shows its reason beside the actions, not in the status line.
const notice = () => tagDialog(page).locator('.dialog-actions > .tag-action-notice');
// Unavailable model.
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
@@ -271,8 +269,7 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
await saveImageTags(request, path, CONCEPT_TAGS);
await openTagDialog(page, 'production', 'dark');
await save();
await expect(notice()).toHaveText('The file changed since it was previewed. Reopen the Tag dialog.');
await expect(status()).toHaveText('');
await expect(status()).not.toHaveText('');
await shot('production-file-changed');
await page.unroute(writes);
await page.keyboard.press('Escape');
@@ -281,7 +278,7 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
await page.route(writes, route => route.fulfill({ status: 504, body: 'Gateway Timeout' }));
await openTagDialog(page, 'production', 'dark');
await save();
await expect(notice()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
await expect(status()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
expect((await inventory(page)).actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
await shot('production-unknown-outcome');
-40
View File
@@ -52,43 +52,3 @@ export async function unseenRestore(page, restoring) {
open();
}
}
// A restore the open page does see run and finish (#415), driven by the test instead of the 15 s poll's timing. The
// job is shaped as the server's (py/api/backup_restore.py: kind `restore`, one `errors` group, not cancellable).
const RESTORE_JOB_ID = '415415415415415415415415415415ab';
const restoreJob = state => ({ id: RESTORE_JOB_ID, kind: 'restore', title: 'Database restore', state, started_at: 1,
finished_at: state === 'running' ? null : 2, progress: 0, status_line: state === 'running' ? '' : 'Restored the test snapshot.',
processed: 0, total: 0, counters: { errors: 0 }, scope: {}, listed: { errors: 0 }, log_length: 0, cancellable: false });
/** The Activity pill's link while the page follows that restore. */
export const SEEN_RESTORE_PILL = `#/activity/${RESTORE_JOB_ID}`;
/** The toast the page shows when it sees that restore finish (activity-kinds.mjs `restore.finishToast`). */
export const SEEN_RESTORE_TOAST = 'Database restore finished · Restored the test snapshot.';
/**
* Call before the page's first navigation, after `armUnseenRestores(page)` (a later route wins). The page's first
* job-list read lists a restore job running, so its Activity store follows it; its job stream is refused, so the store
* reads the list again, and every later read is held until `finish()`, which answers them with the job finished and
* resolves once one of those answers reached the page. The real restore the test runs meanwhile goes through the API.
*/
export async function followRestore(page) {
let release;
const released = new Promise(resolve => { release = resolve; });
let first = true;
await page.route(`${JOBS}/*/events`, route => route.abort().catch(() => {}));
await page.route(JOBS, async route => {
if (first) {
first = false;
return route.fulfill({ json: { success: true, jobs: [restoreJob('running')] } }).catch(() => {});
}
await released;
// A read the page has aborted since (a newer read supersedes it) can no longer be answered.
return route.fulfill({ json: { success: true, jobs: [restoreJob('finished')] } }).catch(() => {});
});
return {
async finish() {
const answered = page.waitForResponse(response => new URL(response.url()).pathname === '/prompt_manager/jobs');
release();
await answered;
},
};
}
@@ -29,19 +29,6 @@ export function pageInventoryOf(main) {
const rows=[...table.querySelectorAll('tbody tr')];
const firstCell=rows[0]?.querySelector('td');
const operations=[...categorySurface.querySelectorAll('.operation')].filter(visible);
const operationOf=row=>{const h3=row.querySelector('h3'),p=row.querySelector('p'),rowActions=row.querySelector('.row-actions');return {
...css(row,'display','flexDirection','justifyContent','alignItems','columnGap','paddingTop','paddingBottom','borderTopWidth','borderTopStyle','borderTopColor'),
height:px(row.getBoundingClientRect().height),h3:{text:h3.textContent,...font(h3)},p:{text:p.textContent,...font(p)},
actions:{...css(rowActions,'flexShrink','flexWrap','columnGap'),buttons:[...rowActions.querySelectorAll('button')].map(buttonOf)}};};
// D149 (#444): production adds the Untagged prompts row and the tag search and sort above the table; the concept has
// neither. The toolbar is compared on its own (toolbarInventoryOf) with the concept's page toolbar as donor.
const untaggedRow=tagSurface.querySelector(':scope > .operation');
const d149=untaggedRow?{untagged:operationOf(untaggedRow),
// It starts where the concept's table starts: right after the paragraph.
offset:px(untaggedRow.getBoundingClientRect().top-tagSurface.getBoundingClientRect().top),
// The vertical space the two additions take: the table starts this much lower than after the paragraph alone.
// (Both offsets rounded first, so offset + shift is exactly the table's rounded offset.)
shift:px(wrap.getBoundingClientRect().top-tagSurface.getBoundingClientRect().top)-px(untaggedRow.getBoundingClientRect().top-tagSurface.getBoundingClientRect().top)}:null;
return {
heading:{...css(heading,'display','flexDirection','justifyContent','alignItems','columnGap','rowGap','marginBottom'),
h1:{text:h1.textContent,tabIndex:h1.tabIndex,...font(h1)},
@@ -67,26 +54,14 @@ export function pageInventoryOf(main) {
rows:rows.map(row=>{const cells=[...row.querySelectorAll('td')];return {cells:cells.length,height:px(row.getBoundingClientRect().height),tagLines:lines(cells[0]),tag:cells[0].textContent,prompts:cells[1].textContent,
images:shownText(cells[2]),imagesHidden:cells[2].querySelector('.ca-visually-hidden')?.textContent??null,
actions:[...cells[3].querySelectorAll('.row-actions button')].map(node=>node.textContent.trim())};})},
operations:operations.map(operationOf),
d149,
operations:operations.map(row=>{const h3=row.querySelector('h3'),p=row.querySelector('p'),rowActions=row.querySelector('.row-actions');return {
...css(row,'display','flexDirection','justifyContent','alignItems','columnGap','paddingTop','paddingBottom','borderTopWidth','borderTopStyle','borderTopColor'),
height:px(row.getBoundingClientRect().height),h3:{text:h3.textContent,...font(h3)},p:{text:p.textContent,...font(p)},
actions:{...css(rowActions,'flexShrink','flexWrap','columnGap'),buttons:[...rowActions.querySelectorAll('button')].map(buttonOf)}};}),
noPageScroll:document.documentElement.scrollWidth<=innerWidth+1,
};
}
/** A `search.toolbar`'s tokens (D149): runs in the page on the concept's page toolbar and on the tag search and sort. */
export function toolbarInventoryOf(root) {
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
const px=value=>Math.round(value);
const field=root.querySelector('.search-field'),input=field.querySelector('input'),select=root.querySelector('select');
return {...css(root,'display','columnGap','rowGap','flexWrap','alignItems','marginBottom'),
field:{...css(field,'display','alignItems','columnGap','flexGrow','minWidth','paddingLeft','paddingRight','borderTopWidth','borderTopStyle',
'borderTopColor','borderTopLeftRadius','backgroundColor'),icon:Boolean(field.querySelector(':scope > svg')),height:px(field.getBoundingClientRect().height)},
input:{type:input.type,...css(input,'fontSize','minHeight','backgroundColor','borderTopWidth','color')},
select:{...css(select,'fontSize','minHeight','backgroundColor','color','borderTopColor','borderTopLeftRadius','paddingLeft'),
height:px(select.getBoundingClientRect().height)}};
}
const countOf=(count,noun)=>`${count} ${noun}${count===1?'':'s'}`;
/**
@@ -106,17 +81,12 @@ export function withImageCounts(operations,{images={},only=[]}={}) {
// Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal.
// `imageCategories` (#332 D143) is the fixture's own image categories, none by default.
export function mapVocabularyConcept(reference,imageCategories={},{untaggedText=null,shift=0}={}) {
export function mapVocabularyConcept(reference,imageCategories={}) {
const operations=reference.operations.map(row=>({...row,p:{...row.p,
// Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243).
text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}}));
// D149 (#444): the Untagged prompts row is drawn as the concept's first category row with its own text and a Browse
// action, and the table moves down by the space it and the tag search and sort take.
const first=reference.operations[0];
const d149=untaggedText===null?null:{untagged:{...first,h3:{...first.h3,text:'Untagged prompts'},p:{...first.p,text:untaggedText}},
offset:reference.table.wrap.offset,shift};
return {...reference,d149,
table:{...reference.table,wrap:{...reference.table.wrap,offset:reference.table.wrap.offset+shift},rows:reference.table.rows.map(row=>({...row,
return {...reference,
table:{...reference.table,rows:reference.table.rows.map(row=>({...row,
// Data, not a deviation: the concept tags each sample image with its prompt's tags; the vocabulary fixture
// tags no image, so every Images count is 0 (#249 C2 retired #245 D3).
images:'0'}))},
@@ -124,14 +94,9 @@ export function mapVocabularyConcept(reference,imageCategories={},{untaggedText=
};
}
/** The D149 mapping inputs: the fixture's Untagged prompts count text (none by default) and production's table shift. */
export function d149Of(actual,untaggedText='0 positive prompts') {
return actual.d149?{untaggedText,shift:actual.d149.shift}:{};
}
/** The page comparison: concept rows are the concept's sample tags; production appends fixture-only tags (D4). */
export function compareVocabularyPage(reference,actual,imageCategories={}) {
const expected=mapVocabularyConcept(reference,imageCategories,d149Of(actual));
const expected=mapVocabularyConcept(reference,imageCategories);
const shared=expected.table.rows.length;
return {diff:diffInventory(expected,{...actual,table:{...actual.table,rows:actual.table.rows.slice(0,shared)}}),
extra:actual.table.rows.slice(shared).map(row=>row.tag)};
@@ -1,868 +0,0 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
// Collections (#331): the sidebar COLLECTIONS group (D136, concept index.html 28–31, app.js 336–339), the collection
// page `#/collection/<id>` with its Edit (D137, concept render a1186–1189) and saved views on a collection (D140).
const workspace = '/prompt_manager/workspace';
const API = '/prompt_manager/collections';
const main = page => page.locator('#workspace-main');
const heading = page => main(page).getByRole('heading', { level: 1 });
const breadcrumb = page => page.locator('#workspace-breadcrumb');
const description = page => main(page).locator('.heading p.muted');
const missingNote = page => main(page).locator('.heading p.small');
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
const imageCards = page => page.locator('article[data-image-key]');
const collectionsNav = page => page.getByRole('navigation', { name: 'Collections', exact: true });
const collectionsLabel = page => page.locator('aside.sidebar .sidebar-label', { hasText: 'COLLECTIONS' });
const rows = page => collectionsNav(page).locator('a[data-route^="collection/"]');
const row = (page, name) => rows(page).filter({ hasText: name });
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
const editButton = page => main(page).locator('.heading-actions > button').first();
const editDialog = page => page.getByRole('dialog', { name: 'Edit collection', exact: true });
const deleteDialog = page => page.getByRole('dialog', { name: 'Delete collection', exact: true });
const nameField = page => editDialog(page).getByRole('textbox', { name: 'Collection name', exact: true });
const toast = page => page.locator('#workspace-toast');
async function seed(request) {
const response = await request.post('/__test__/collections/seed');
await expect(response).toBeOK();
return response.json();
}
async function listed(request) {
const response = await request.get(API);
await expect(response).toBeOK();
return (await response.json()).collections;
}
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.collectionErrors = [];
page.on('pageerror', error => page.collectionErrors.push(error.message));
// D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open.
page.on('dialog', dialog => { page.collectionErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.collectionErrors).toEqual([]);
});
test('a collection row opens its page', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/images`);
// D136: name order, the listed-member count, no "PROPOSED" badge.
await expect(collectionsLabel(page)).toBeVisible();
await expect(collectionsLabel(page)).toHaveText('COLLECTIONS');
await expect(rows(page)).toHaveCount(2);
await expect(rows(page).nth(0)).toContainText('Experiments');
await expect(rows(page).nth(1)).toContainText('Landscapes');
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('8');
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('12');
await expect(row(page, 'Landscapes')).toHaveAttribute('href', `#/collection/${id}`);
await expect(row(page, 'Landscapes').locator('svg')).toHaveCount(1);
await expect(page.locator('aside.sidebar .new-label')).toHaveCount(0);
await expect(imageCards(page)).toHaveCount(21);
await row(page, 'Landscapes').click();
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
await expect(heading(page)).toHaveText('Landscapes');
await expect(breadcrumb(page)).toHaveText('Collection');
await expect(page).toHaveTitle('Collection · Prompt Manager');
await expect(row(page, 'Landscapes')).toHaveAttribute('aria-current', 'page');
await expect(description(page)).toHaveText('Your generations, their prompts, and the next idea.');
await expect(imageCards(page)).toHaveCount(12);
for (const path of manifest.files.Landscapes) {
await expect(page.locator(`article[data-image-key$=":${path}"]`)).toHaveCount(1);
}
// MC3: the member whose file is gone is not listed and is counted in the note.
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
// Every Images filter narrows the members.
await search(page).fill('landscape_03');
await expect(imageCards(page)).toHaveCount(1);
await search(page).fill('');
await expect(imageCards(page)).toHaveCount(12);
await row(page, 'Experiments').click();
await expect(heading(page)).toHaveText('Experiments');
await expect(imageCards(page)).toHaveCount(8);
await expect(missingNote(page)).toHaveCount(0);
});
test('without collections the group shows no label and no rows', async ({ page }) => {
await page.goto(`${workspace}#/images`);
await expect(imageCards(page).first()).toBeVisible();
await expect(collectionsNav(page)).toHaveCount(1);
await expect(rows(page)).toHaveCount(0);
await expect(collectionsLabel(page)).toBeHidden();
});
test('a collection page neither restores nor replaces the Images return state', async ({ page, request }) => {
const manifest = await seed(request);
await page.goto(`${workspace}#/images`);
await search(page).fill('experiment');
await expect(imageCards(page)).toHaveCount(8);
await row(page, 'Landscapes').click();
await expect(search(page)).toHaveValue('');
await expect(imageCards(page)).toHaveCount(12);
await search(page).fill('landscape_01');
await expect(imageCards(page)).toHaveCount(1);
await page.goto(`${workspace}#/images`);
await expect(search(page)).toHaveValue('experiment');
expect(manifest.collections.Landscapes.count).toBe(12);
});
test('Edit renames the open collection; a taken name stays in the dialog', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.goto(`${workspace}#/collection/${id}`);
await expect(heading(page)).toHaveText('Landscapes');
await expect(editButton(page)).toHaveText('Edit');
await editButton(page).click();
await expect(nameField(page)).toHaveValue('Landscapes');
await expect(editDialog(page).locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']);
// CUJ 3: names are unique ignoring case.
await nameField(page).fill('experiments');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(editDialog(page).getByRole('alert')).toHaveText('A collection named “experiments” already exists.');
await expect(heading(page)).toHaveText('Landscapes');
await nameField(page).fill(' Landscape grids ');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(editDialog(page)).toHaveCount(0);
await expect(toast(page)).toHaveText('Collection renamed.');
await expect(heading(page)).toHaveText('Landscape grids');
await expect(breadcrumb(page)).toHaveText('Collection');
await expect(row(page, 'Landscape grids')).toHaveAttribute('aria-current', 'page');
await expect(rows(page).nth(1)).toContainText('Landscape grids');
expect((await listed(request)).map(item => item.name)).toEqual(['Experiments', 'Landscape grids']);
});
test('a failed rename keeps the dialog; a failed delete changes nothing', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
await page.goto(`${workspace}#/collection/${id}`);
await expect(heading(page)).toHaveText('Experiments');
let failing = true;
await page.route(`**${API}/${id}`, route => failing
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"The collection could not be changed."}' })
: route.fallback());
await editButton(page).click();
await nameField(page).fill('Tests');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('The collection could not be renamed. Try again.');
await expect(editDialog(page)).toBeVisible();
await expect(heading(page)).toHaveText('Experiments');
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await expect(deleteDialog(page).locator('p')).toHaveText(
'Delete “Experiments”? Its 8 images stay where they are; only the collection is removed.');
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('The collection could not be deleted. Try again.');
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
expect(await listed(request)).toHaveLength(2);
failing = false;
});
test('Delete removes the collection only after confirming, and leaves every file', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.goto(`${workspace}#/collection/${id}`);
await editButton(page).click();
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
// The stored count: 12 listed members and the missing one.
await expect(deleteDialog(page).locator('p')).toHaveText(
'Delete “Landscapes”? Its 13 images stay where they are; only the collection is removed.');
await deleteDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(deleteDialog(page)).toHaveCount(0);
expect(await listed(request)).toHaveLength(2);
await editButton(page).click();
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('Collection deleted.');
await expect(page).toHaveURL(/#\/images$/);
await expect(heading(page)).toHaveText('Images');
await expect(rows(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveCount(21);
expect((await listed(request)).map(item => item.name)).toEqual(['Experiments']);
});
test('an unknown collection says it no longer exists, with the way back to Images', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/collection/999999`);
await expect(heading(page)).toHaveText('Collection');
await expect(breadcrumb(page)).toHaveText('Collection');
await expect(description(page)).toHaveText('This collection no longer exists.');
await expect(imageCards(page)).toHaveCount(0);
await main(page).getByRole('link', { name: 'Open Images', exact: true }).click();
await expect(page).toHaveURL(/#\/images$/);
await expect(imageCards(page)).toHaveCount(21);
});
test('a failed list says so and Retry reads it again', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
let failing = true;
await page.route(`**${API}`, route => failing
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' })
: route.fallback());
await page.goto(`${workspace}#/collection/${id}`);
await expect(description(page)).toHaveText('Collections could not be loaded. Your saved data has not changed.');
await expect(description(page)).toHaveAttribute('role', 'alert');
await expect(rows(page)).toHaveCount(0);
failing = false;
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
await expect(heading(page)).toHaveText('Experiments');
await expect(imageCards(page)).toHaveCount(8);
await expect(rows(page)).toHaveCount(2);
});
test('a saved view keeps its collection, and says so once the collection is deleted (MC6)', async ({ page, request }) => {
const manifest = await seed(request);
const view = manifest.saved_view;
await page.goto(`${workspace}#/images`);
await savedNav(page).locator(`a[data-route="saved/${view.id}"]`).click();
await expect(heading(page)).toHaveText(view.name);
await expect(breadcrumb(page)).toHaveText(view.name);
await expect(imageCards(page)).toHaveCount(8);
// A saved view is not the collection page: no missing note.
await expect(missingNote(page)).toHaveCount(0);
// Save view on a collection page stores the collection.
await row(page, 'Landscapes').click();
await expect(imageCards(page)).toHaveCount(12);
await search(page).fill('landscape_1');
await expect(imageCards(page)).toHaveCount(3);
await main(page).getByRole('button', { name: 'Save view', exact: true }).click();
const save = page.getByRole('dialog', { name: 'Save this view', exact: true });
await save.getByRole('textbox', { name: 'View name', exact: true }).fill('Grid seed');
await save.getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
const views = (await (await request.get('/prompt_manager/saved-views')).json()).views;
const saved = views.find(item => item.name === 'Grid seed');
expect(saved.state).toMatchObject({ q: 'landscape_1', favorites: false, collection: String(manifest.collections.Landscapes.id) });
await expect(await request.delete(`${API}/${manifest.collections.Experiments.id}`)).toBeOK();
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText(view.name);
await expect(main(page).locator('.empty')).toHaveText('The collection this view uses was deleted.');
await expect(main(page).locator('.empty')).toBeVisible();
await expect(imageCards(page)).toHaveCount(0);
await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden();
});
test('a collection deleted in another tab shows the deleted-collection page', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
await page.goto(`${workspace}#/images`);
await expect(rows(page)).toHaveCount(2);
await expect(await request.delete(`${API}/${id}`)).toBeOK();
await row(page, 'Experiments').click();
await expect(description(page)).toHaveText('This collection no longer exists.');
await expect(rows(page)).toHaveCount(1);
});
// D136 / CUJ 15: the rows show and open at desktop and phone width in both themes. The concept's own phone rule hides
// every nav count there, so the rows keep their counts only at desktop width.
for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['dark', 'light']) {
test(`collection rows show and open at ${width}x${height} ${theme}`, async ({ page, request }) => {
const manifest = await seed(request);
await page.setViewportSize({ width, height });
await page.goto(`${workspace}#/images`);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
for (const name of ['Experiments', 'Landscapes']) await expect(row(page, name)).toBeVisible();
const count = row(page, 'Landscapes').locator('.nav-count');
if (width > 950) await expect(count).toBeVisible();
else await expect(count).toBeHidden();
// Phone width: the label stays hidden as every sidebar label is; desktop shows it.
if (width > 950) await expect(collectionsLabel(page)).toBeVisible();
else await expect(collectionsLabel(page)).toBeHidden();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await row(page, 'Landscapes').click();
await expect(page).toHaveURL(new RegExp(`#/collection/${manifest.collections.Landscapes.id}$`));
await expect(heading(page)).toHaveText('Landscapes');
await expect(imageCards(page)).toHaveCount(12);
});
}
test('a finished restore reads the collections again', async ({ page, request }) => {
await seed(request);
await expect(await request.post('/__test__/system/reset')).toBeOK();
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
try {
const backup = await request.get('/prompt_manager/backup');
await expect(backup).toBeOK();
const bytes = await backup.body();
const [experiments] = await listed(request);
await expect(await request.delete(`${API}/${experiments.id}`)).toBeOK();
await page.goto(`${workspace}#/system/backups`);
await expect(rows(page)).toHaveCount(1);
const chooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Review restore…', exact: true }).click();
await (await chooser).setFiles({ name: 'prompts_backup_20261027_093000.db', mimeType: 'application/octet-stream', buffer: bytes });
await page.locator('dialog#modal[open]').getByRole('button', { name: 'Restore', exact: true }).click();
await expect(toast(page)).toHaveText(/^Database restore finished · /);
// The list was read again without a reload.
await expect(rows(page)).toHaveCount(2);
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('8');
} finally {
await request.post('/__test__/jobs/restart');
await request.post('/__test__/system/reset');
}
});
test('renaming a collection deleted in another tab says so and shows the deleted-collection page', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
await page.goto(`${workspace}#/collection/${id}`);
await expect(heading(page)).toHaveText('Experiments');
await editButton(page).click();
await expect(await request.delete(`${API}/${id}`)).toBeOK();
await nameField(page).fill('Tests');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('That collection no longer exists.');
await expect(editDialog(page)).toHaveCount(0);
await expect(description(page)).toHaveText('This collection no longer exists.');
await expect(rows(page)).toHaveCount(1);
});
/** A Playwright hold on the collections list read: `held` fills as reads arrive; `release()` lets them all through. */
async function holdListReads(page, answer = route => route.fallback()) {
const held = [];
let open = false;
await page.route(`**${API}`, route => {
if (route.request().method() !== 'GET') return route.fallback();
if (open) return answer(route);
held.push(route);
return undefined;
});
return {
held,
async release() {
open = true;
for (const route of held.splice(0)) await answer(route).catch(() => {});
},
};
}
test('a list read superseded by another read still opens a collection made since (review M1)', async ({ page, request }) => {
const manifest = await seed(request);
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1')).json();
const [file] = listing.images;
await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`);
await expect(heading(page)).toHaveText('Landscapes');
// Another tab makes "Fresh"; this tab does not list it yet.
const added = await request.post(`${API}/items`, { data: { name: 'Fresh',
items: [{ root_index: file.root_index, root_scope: file.root_scope, path: file.path }] } });
await expect(added).toBeOK();
const fresh = (await added.json()).collection.id;
await expect(rows(page)).toHaveCount(2);
const hold = await holdListReads(page);
// A confirmed rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The
// timer's read supersedes that one while both are held. The rename's answer is held until "Fresh"'s read is, so
// the timer starts only then: with the answer let through at once, a slow host could fire the timer before the
// hash change, and the page would then wait on the timer's read alone (#459).
let releaseRename;
const renameHeld = new Promise(resolve => { releaseRename = resolve; });
await page.route(`**${API}/${manifest.collections.Landscapes.id}`, async route => {
if (route.request().method() !== 'PATCH') return route.fallback();
await renameHeld;
return route.fallback();
});
await editButton(page).click();
await nameField(page).fill('Landscape grids');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await page.evaluate(hash => { window.location.hash = hash; }, `#/collection/${fresh}`);
await expect.poll(() => hold.held.length).toBe(1);
releaseRename();
// The rename landed (the sidebar row has the new name), so the count read is now due.
await expect(row(page, 'Landscape grids')).toHaveCount(1);
await expect.poll(() => hold.held.length).toBe(2);
await expect(main(page)).not.toContainText('Collections could not be loaded.');
await hold.release();
await expect(heading(page)).toHaveText('Fresh');
await expect(imageCards(page)).toHaveCount(1);
});
test('a listing that finds the collection gone while the list still has it offers Retry (review M2)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
let gone = true;
await page.route('**/prompt_manager/images/output?*', route => gone && route.request().url().includes(`collection=${id}`)
? route.fulfill({ status: 404, contentType: 'application/json',
body: '{"success":false,"error":"Collection not found","code":"collection-not-found"}' })
: route.fallback());
await page.goto(`${workspace}#/images`);
await expect(rows(page)).toHaveCount(2);
// The shell's re-read is held, then fails: the page must not stay blank.
const hold = await holdListReads(page, route => route.fulfill({ status: 500, contentType: 'application/json',
body: '{"success":false,"error":"Collections could not be read."}' }));
await row(page, 'Experiments').click();
await expect.poll(() => hold.held.length).toBe(1);
await hold.release();
const retry = main(page).getByRole('button', { name: 'Retry', exact: true });
await expect(retry).toBeVisible();
await expect(main(page).locator('#gallery-continuation p')).toHaveText('Images could not be loaded. Retry or rescan the collection.');
gone = false;
await page.unroute(`**${API}`);
await retry.click();
await expect(imageCards(page)).toHaveCount(8);
// The list read again still lists it: the same Retry, never a blank page.
gone = true;
await page.evaluate(() => { window.location.hash = '#/images'; });
await expect(heading(page)).toHaveText('Images');
await row(page, 'Experiments').click();
await expect(retry).toBeVisible();
gone = false;
await retry.click();
await expect(imageCards(page)).toHaveCount(8);
});
test('the missing-members note follows the list read again (review L2)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
let missing = null;
await page.route(`**${API}`, async route => {
if (route.request().method() !== 'GET' || missing === null) return route.fallback();
const response = await route.fetch();
const data = await response.json();
data.collections = data.collections.map(item => item.id === id ? { ...item, missing } : item);
return route.fulfill({ response, json: data });
});
await page.goto(`${workspace}#/collection/${id}`);
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
const rename = async name => {
await editButton(page).click();
await nameField(page).fill(name);
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collection renamed.');
};
missing = 3;
await rename('Landscapes A');
await expect(missingNote(page)).toHaveText('3 images in this collection are not in your current sources.');
missing = 0;
await rename('Landscapes B');
await expect(missingNote(page)).toHaveCount(0);
missing = 1;
await rename('Landscapes C');
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
});
test('a rename that settles after its dialog closed leaves the next dialog open (review L1)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.goto(`${workspace}#/collection/${id}`);
await expect(imageCards(page)).toHaveCount(12);
let release;
const released = new Promise(resolve => { release = resolve; });
await page.route(`**${API}/${id}`, async route => {
if (route.request().method() !== 'PATCH') return route.fallback();
await released;
return route.fallback();
});
await editButton(page).click();
await nameField(page).fill('Later');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await page.keyboard.press('Escape');
await expect(editDialog(page)).toHaveCount(0);
await main(page).getByRole('button', { name: 'Save view', exact: true }).click();
const save = page.getByRole('dialog', { name: 'Save this view', exact: true });
await expect(save).toBeVisible();
release();
await expect(heading(page)).toHaveText('Later');
await expect(save).toBeVisible();
});
test('a page that says a collection or view could not be opened takes focus after a route change (review L3)', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(heading(page)).toHaveText('Images');
await page.evaluate(() => { window.location.hash = '#/collection/999999'; });
await expect(description(page)).toHaveText('This collection no longer exists.');
await expect(heading(page)).toBeFocused();
await page.route(`**${API}`, route => route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
: route.fallback());
await page.evaluate(() => { window.location.hash = '#/images'; });
await expect(heading(page)).toHaveText('Images');
await page.evaluate(() => { window.location.hash = '#/collection/999998'; });
await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeVisible();
await expect(heading(page)).toBeFocused();
await page.route('**/prompt_manager/saved-views', route => route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
: route.fallback());
await page.evaluate(() => { window.location.hash = '#/saved/5'; });
await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
await expect(heading(page)).toBeFocused();
});
// #331 D138 (design §7): the selection bar's Add to collection and Remove from collection.
const bar = page => page.locator('#bulkbar > .bulkbar');
const barButton = (page, name) => bar(page).getByRole('button', { name, exact: true });
const addDialog = page => page.getByRole('dialog', { name: 'Add selected images to collection', exact: true });
const addField = page => addDialog(page).getByRole('combobox', { name: 'Collection name', exact: true });
const check = (page, file) => page.getByRole('checkbox', { name: `Select ${file}`, exact: true }).check();
const card = (page, path) => page.locator(`article[data-image-key$=":${path}"]`);
/** Add the checked images under `name` through the bar's dialog. */
async function addChecked(page, name) {
await barButton(page, 'Add to collection').click();
await expect(addDialog(page)).toBeVisible();
await addField(page).fill(name);
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(addDialog(page)).toHaveCount(0);
}
test('Add to collection creates a collection and adds to it ignoring case (CUJ 1–3)', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
for (const file of ['loose.png', 'landscape_01.png', 'experiment_01.png']) await check(page, file);
await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Export metadata', 'Clear']);
await barButton(page, 'Add to collection').click();
// The concept's simpleInput with Add (D138) and the existing names as suggestions.
await expect(addDialog(page).locator('label')).toHaveText('Collection name');
await expect(addField(page)).toHaveValue('');
await expect(addField(page)).toHaveAttribute('maxlength', '100');
await expect(addDialog(page).locator('datalist option')).toHaveCount(2);
expect(await addDialog(page).locator('datalist option').evaluateAll(options => options.map(option => option.value)))
.toEqual(['Experiments', 'Landscapes']);
await expect(addDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Add']);
// An empty name keeps the dialog on its field.
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(addField(page)).toBeFocused();
await addField(page).fill(' LoRa testing ');
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(toast(page)).toHaveText('Added 3 images to “LoRa testing”.');
await expect(bar(page).locator('strong')).toHaveText('3 selected');
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('3');
await expect(rows(page)).toHaveCount(3);
// CUJ 2: one of two already in it.
await bar(page).getByRole('button', { name: 'Clear', exact: true }).click();
await check(page, 'loose.png');
await check(page, 'landscape_02.png');
await addChecked(page, 'LoRa testing');
await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”. 1 was already in it.');
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('4');
// CUJ 3: a case variant joins the same collection.
await bar(page).getByRole('button', { name: 'Clear', exact: true }).click();
await check(page, 'landscape_03.png');
await addChecked(page, 'lora TESTING');
await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”.');
await expect(rows(page)).toHaveCount(3);
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('5');
const created = (await listed(request)).find(item => item.name === 'LoRa testing');
expect(created).toMatchObject({ count: 5, missing: 0 });
// Everything already in it.
await addChecked(page, 'LoRa testing');
await expect(toast(page)).toHaveText('All selected images were already in “LoRa testing”.');
});
test('Remove from collection takes the checked images off the open collection (CUJ 5)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.goto(`${workspace}#/collection/${id}`);
await expect(imageCards(page)).toHaveCount(12);
await check(page, 'landscape_01.png');
await check(page, 'landscape_02.png');
await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Remove from collection', 'Export metadata', 'Clear']);
await barButton(page, 'Remove from collection').click();
await expect(toast(page)).toHaveText('Removed 2 images from “Landscapes”.');
await expect(imageCards(page)).toHaveCount(10);
await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0);
// The removed files left the listing, so the bar has nothing checked.
await expect(bar(page)).toHaveCount(0);
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('10');
// A checked file still listed stays checked across the reload.
await check(page, 'landscape_03.png');
await check(page, 'landscape_04.png');
await page.route(`**${API}/items`, async route => {
if (route.request().method() !== 'DELETE') return route.fallback();
// Remove only landscape_04; landscape_03 stays listed and checked.
const body = route.request().postDataJSON();
await route.continue({ postData: JSON.stringify({ ...body, items: body.items.filter(item => item.path.endsWith('_04.png')) }) });
});
await barButton(page, 'Remove from collection').click();
await expect(toast(page)).toHaveText('Removed 1 image from “Landscapes”.');
await expect(imageCards(page)).toHaveCount(9);
await expect(bar(page).locator('strong')).toHaveText('1 selected');
await expect(page.getByRole('checkbox', { name: 'Select landscape_03.png', exact: true })).toBeChecked();
// No file changed: the plain Images page still lists all of them.
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
});
test('Images without a collection filter has no Remove from collection', async ({ page, request }) => {
const manifest = await seed(request);
await page.goto(`${workspace}#/images`);
await check(page, 'loose.png');
await expect(barButton(page, 'Add to collection')).toBeVisible();
await expect(barButton(page, 'Remove from collection')).toHaveCount(0);
// A saved view on a collection has its filter active: Remove shows (design §5).
await page.goto(`${workspace}#/saved/${manifest.saved_view.id}`);
await expect(imageCards(page)).toHaveCount(8);
await check(page, 'experiment_01.png');
await barButton(page, 'Remove from collection').click();
await expect(toast(page)).toHaveText('Removed 1 image from “Experiments”.');
await expect(imageCards(page)).toHaveCount(7);
});
test('a failed add keeps the selection; a stale source reloads the images', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
let reply = { status: 500, body: { success: false, error: 'The collection could not be changed.' } };
await page.route(`**${API}/items`, route => route.fulfill({ status: reply.status, contentType: 'application/json',
body: JSON.stringify(reply.body) }));
await check(page, 'loose.png');
await check(page, 'landscape_05.png');
await addChecked(page, 'Grids');
await expect(toast(page)).toHaveText('The images could not be added. Try again.');
await expect(bar(page).locator('strong')).toHaveText('2 selected');
await expect(rows(page)).toHaveCount(2);
reply = { status: 404, body: { success: false, code: 'files-not-found', error: 'x' } };
await addChecked(page, 'Grids');
await expect(toast(page)).toHaveText('None of the selected images could be found. Refresh the images and try again.');
await expect(bar(page).locator('strong')).toHaveText('2 selected');
reply = { status: 409, body: { success: false, code: 'source-changed', error: 'x' } };
const reloaded = page.waitForRequest(request => request.url().includes('/prompt_manager/gallery/subfolders'));
await addChecked(page, 'Grids');
await expect(toast(page)).toHaveText('The sources changed. Refresh the images and try again.');
await reloaded;
await expect(imageCards(page)).toHaveCount(21);
await expect(bar(page)).toHaveCount(0);
});
test('more than 500 or an unloaded "all matching" selection is refused with the cap', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(21);
// Every loaded image checked and the listing says more match: "Select all N matching" (#392, D135).
await page.route('**/prompt_manager/images/output?*', async route => {
// A next page never answers, so the listing stays at its loaded 21 of 900.
if (new URL(route.request().url()).searchParams.has('cursor')) return;
const response = await route.fetch();
const data = await response.json();
await route.fulfill({ response, json: { ...data, total: 900, has_more: true, next_cursor: 'more' } });
});
await page.reload();
await expect(imageCards(page)).toHaveCount(21);
await page.locator('#image-grid .image-open').first().focus();
await page.keyboard.press('ControlOrMeta+a');
await bar(page).getByRole('button', { name: 'Select all 900 matching', exact: true }).click();
await expect(bar(page).locator('strong')).toHaveText('All 900 matching selected');
await barButton(page, 'Add to collection').click();
await expect(toast(page)).toHaveText('Select at most 500 images.');
await expect(addDialog(page)).toHaveCount(0);
});
for (const theme of ['dark', 'light']) {
test(`the Add to collection dialog fits 390x844 ${theme}`, async ({ page, request }) => {
await seed(request);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`${workspace}#/images`);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await check(page, 'loose.png');
await expect(barButton(page, 'Add to collection')).toBeVisible();
await barButton(page, 'Add to collection').click();
await expect(addDialog(page)).toBeVisible();
const fits = await addDialog(page).evaluate(dialog => {
const box = dialog.getBoundingClientRect();
return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth;
});
expect(fits).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await addField(page).fill('Phone');
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
await expect(toast(page)).toHaveText('Added 1 image to “Phone”.');
await expect(row(page, 'Phone')).toBeVisible();
});
}
// #331 D139 (design §8): the shared inspector's Collections… in the Images side panel and in the viewer.
const panel = page => page.locator('#image-inspector');
const collectionsDialog = page => page.getByRole('dialog', { name: 'Collections', exact: true });
const choice = (page, name) => collectionsDialog(page).getByRole('checkbox', { name, exact: true });
const newCollection = page => collectionsDialog(page).getByRole('textbox', { name: 'New collection', exact: true });
async function membership(request, path) {
const [image] = (await (await request.get(`/prompt_manager/images/output?mode=workspace&q=${encodeURIComponent(path)}`)).json()).images;
const query = new URLSearchParams({ root_index: String(image.root_index), root_scope: image.root_scope, path: image.path });
const response = await request.get(`${API}/membership?${query}`);
await expect(response).toBeOK();
return (await response.json()).collection_ids;
}
test('Collections… in the side panel adds, removes and creates for one image (CUJ 6)', async ({ page, request }) => {
const manifest = await seed(request);
const { Landscapes, Experiments } = manifest.collections;
await page.goto(`${workspace}#/images`);
// The newest image, landscape_01, is the panel's.
await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('landscape_01.png');
const open = panel(page).getByRole('button', { name: 'Collections…', exact: true });
await expect(panel(page).locator('.detail-footer > button').last()).toHaveText('Collections…');
await open.click();
await expect(collectionsDialog(page)).toBeVisible();
await expect(choice(page, 'Landscapes')).toBeChecked();
await expect(choice(page, 'Experiments')).not.toBeChecked();
await expect(collectionsDialog(page).locator('label.tag-suggestion')).toHaveText(['Experiments', 'Landscapes']);
await expect(collectionsDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']);
await choice(page, 'Landscapes').uncheck();
await choice(page, 'Experiments').check();
await newCollection(page).fill('Grids');
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page)).toHaveCount(0);
await expect(toast(page)).toHaveText('Collections updated.');
await expect(open).toBeFocused();
await expect(row(page, 'Grids').locator('.nav-count')).toHaveText('1');
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11');
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('9');
const grids = (await listed(request)).find(item => item.name === 'Grids');
expect((await membership(request, 'landscape_01')).sort()).toEqual([Experiments.id, grids.id].sort());
expect(Landscapes.id).not.toBe(grids.id);
// The Images page lists the same files; the dialog opened again shows the new state.
await expect(imageCards(page)).toHaveCount(21);
await open.click();
await expect(choice(page, 'Grids')).toBeChecked();
await expect(choice(page, 'Experiments')).toBeChecked();
await expect(choice(page, 'Landscapes')).not.toBeChecked();
// Save without a change sends nothing.
let writes = 0;
page.on('request', request => { if (request.url().includes(`${API}/items`)) writes++; });
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page)).toHaveCount(0);
expect(writes).toBe(0);
});
test('Collections… in the viewer keeps the viewer until it closes (CUJ 7)', async ({ page, request }) => {
const manifest = await seed(request);
await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`);
await expect(imageCards(page)).toHaveCount(12);
await page.getByRole('button', { name: 'Open landscape_01.png', exact: true }).click();
const viewer = page.locator('#viewer-dialog[open]');
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
// Review L7: no listing request while the viewer is open.
let listings = 0;
const countListing = request => { if (new URL(request.url()).pathname === '/prompt_manager/images/output') listings++; };
page.on('request', countListing);
await viewer.locator('#viewer-details').getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
// Review L6: focus moves to the first checkbox once listed.
await expect(choice(page, 'Experiments')).toBeFocused();
await choice(page, 'Landscapes').uncheck();
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collections updated.');
await expect(collectionsDialog(page)).toHaveCount(0);
// The viewer stays on the image; the page keeps listing it while the viewer is open.
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11');
await expect(imageCards(page)).toHaveCount(12);
expect(listings, 'no listing request while the viewer is open').toBe(0);
page.off('request', countListing);
await viewer.getByRole('button', { name: 'Close image viewer', exact: true }).click();
await expect(viewer).toHaveCount(0);
await expect(imageCards(page)).toHaveCount(11);
await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0);
});
test('a failed membership read says so and Retry reads it again', async ({ page, request }) => {
await seed(request);
let failing = true;
await page.route(`**${API}/membership?*`, route => failing
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' })
: route.fallback());
await page.goto(`${workspace}#/images`);
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(collectionsDialog(page).getByRole('alert').first()).toHaveText('Collections could not be loaded.');
await expect(collectionsDialog(page).getByRole('checkbox')).toHaveCount(0);
await expect(collectionsDialog(page).getByRole('button', { name: 'Save', exact: true })).toHaveAttribute('aria-disabled', 'true');
failing = false;
await collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
await expect(collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden();
// A failed write keeps the dialog with the current membership.
await page.route(`**${API}/items`, route => route.fulfill({ status: 500, contentType: 'application/json',
body: '{"success":false,"error":"The collection could not be changed."}' }));
await choice(page, 'Experiments').check();
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.');
await expect(choice(page, 'Experiments')).not.toBeChecked();
await expect(choice(page, 'Landscapes')).toBeChecked();
});
test('a file in no collection with no collections at all can start one', async ({ page }) => {
await page.goto(`${workspace}#/images`);
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(collectionsDialog(page)).toContainText('No collections yet. Name one below.');
await newCollection(page).fill('First');
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collections updated.');
await expect(rows(page)).toHaveCount(1);
await expect(row(page, 'First').locator('.nav-count')).toHaveText('1');
});
for (const theme of ['dark', 'light']) {
test(`the Collections dialog fits 390x844 ${theme}`, async ({ page, request }) => {
await seed(request);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`${workspace}#/images`);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
const fits = await collectionsDialog(page).evaluate(dialog => {
const box = dialog.getBoundingClientRect();
return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth;
});
expect(fits).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
}
// Review M2: on a collection page, a Save that removed the file from the open collection and then failed keeps its
// dialog and message; the listing is read again only once the dialog closes.
test('a partly failed Save from the side panel keeps its dialog until it closes', async ({ page, request }) => {
const manifest = await seed(request);
const { Experiments, Landscapes } = manifest.collections;
// experiment_01 is in both collections; Experiments sorts first, so its remove is sent before Landscapes'.
const [image] = (await (await request.get('/prompt_manager/images/output?mode=workspace&q=experiment_01')).json()).images;
const item = { root_index: image.root_index, root_scope: image.root_scope, path: image.path };
await expect(await request.post(`${API}/items`, { data: { collection_id: Landscapes.id, items: [item] } })).toBeOK();
await page.route(`**${API}/items`, route => {
const body = route.request().postDataJSON();
return route.request().method() === 'DELETE' && body.collection_id === Landscapes.id
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
: route.fallback();
});
await page.goto(`${workspace}#/collection/${Experiments.id}`);
await expect(imageCards(page)).toHaveCount(8);
await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('experiment_01.png');
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
await expect(choice(page, 'Landscapes')).toBeChecked();
await choice(page, 'Experiments').uncheck();
await choice(page, 'Landscapes').uncheck();
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.');
// The remove from Experiments went through; the dialog shows it and the page has not reloaded under it.
await expect(choice(page, 'Experiments')).not.toBeChecked();
await expect(choice(page, 'Landscapes')).toBeChecked();
await expect(imageCards(page)).toHaveCount(8);
await collectionsDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(collectionsDialog(page)).toHaveCount(0);
await expect(imageCards(page)).toHaveCount(7);
await expect(card(page, 'collections/experiment_01.png')).toHaveCount(0);
});
-19
View File
@@ -495,25 +495,6 @@ test('the inline current file names its prompts apart from the frozen run fields
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
});
// #449 (D154): History's inline current file draws tags in the shared labelled sections, named with its context so they
// stay apart from the frozen run's fields. The server sends History no image_tags facet today (D88), so the read is
// given one here to prove the shared render in History.
test('the inline current file labels its image tags after its negative prompt',async({page})=>{
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
body.image_tags={state:'current',tags:['history_tag']};
await route.fulfill({response,json:body});});
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});
const tags=current.getByRole('region',{name:'Current file image tags',exact:true});
await expect(tags.locator('.role-label')).toHaveText('Image tags · 1');await expect(tags.locator('.tags > .chip')).toHaveText(['history_tag']);
const negative=current.getByRole('region',{name:'Current file negative prompt',exact:true});
expect(await negative.evaluate((node,section)=>({after:Boolean(node.compareDocumentPosition(section)&Node.DOCUMENT_POSITION_FOLLOWING),inside:node.contains(section)}),await tags.elementHandle())).toEqual({after:true,inside:false});
});
// #332 CUJ 5 (MC8, §5.3): History's current-file inspector reads the file's own category with the Images key rule, so
// the inline current file and its viewer show "Portraits · this image"; current.png has two saved links, so it inherits
// nothing and shows no chip before.
+5 -12
View File
@@ -1,6 +1,6 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept, openConcept, openProduction, startInGrid } from './concept-inventory.mjs';
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
const workspace = '/prompt_manager/workspace#/images';
const cards = page => page.locator('article[data-image-key]');
@@ -569,10 +569,6 @@ test('source and folder discovery failure stays visible and Import Rescan restor
async function staleSourceRecovery(page, request, roots) {
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
await page.setViewportSize({ width: 1600, height: 1100 });
// #430: the page first asks the server where a stale file is now. These tests cover the manual Refresh, so the
// file is never found; workspace-stale-source.spec.mjs covers a renumbered file that is found again.
await page.route('**/prompt_manager/images/rescope', route => route.fulfill({ status: 409,
json: { success: false, code: 'source-changed', error: 'The source of this image changed or was removed.' } }));
await page.goto(workspace);
const source = refineDialog(page).getByLabel('Source', { exact: true });
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
@@ -814,8 +810,6 @@ test('leaving during Rescan cancels it without replacing the next workspace', as
test('Images uses approved geometry while keeping real controls', async ({page,request}) => {
await request.post('/__test__/reset',{data:{workspace_visual:true}});
// The approved Grid geometry (D157: the default layout is Masonry).
await startInGrid(page);
await page.setViewportSize({width:1600,height:1100});
await page.goto(workspace);
await expect(cards(page)).toHaveCount(24);
@@ -897,8 +891,7 @@ test('images toolbar and filter row match the concept controls', async ({ page,
// app.js toolbar() a347–360: search, category, sort, Save view, Filters.
await expect(main.locator('search.toolbar > *')).toHaveCount(5);
await expect(main.locator('search.toolbar > button')).toHaveText(['Save view', 'Filters']);
// D153 (#447): no "Highest rated", even with a rated prompt in the library.
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Oldest first', 'Name Z–A']);
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Name Z–A']);
const row = main.locator('.filter-row');
await expect(row.locator(':scope > *')).toHaveCount(5);
await expect(row.locator(':scope > .segmented[role="group"][aria-label="Image layout"] > button')).toHaveText(['Grid', 'List', 'Masonry']);
@@ -917,8 +910,8 @@ test('images toolbar and filter row match the concept controls', async ({ page,
await search(page).fill('');
await expect(cards(page)).toHaveCount(2);
await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png');
await sortBy(page).selectOption('name-desc');
await expect(sortBy(page)).toHaveValue('name-desc');
await sortBy(page).selectOption('rating');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
await sortBy(page).selectOption('newest');
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
await expect(cards(page)).toHaveCount(1);
@@ -1794,7 +1787,7 @@ test('card checkboxes select images into the concept bar and Clear empties it',
await checkbox(first, 'same.png').check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
// D85: Add tags (the concept's) and Auto-tag selected lead the real actions.
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']);
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']);
// Toggling neither opens the viewer, nor inspects, nor moves focus.
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(checkbox(first, 'same.png')).toBeFocused();
+9 -136
View File
@@ -8,11 +8,10 @@ import { parityPng, localFixture, tinyWebm, sendablePng, corruptPng, disguised,
const workspace = '/prompt_manager/workspace';
const TOAST = 'Local preview only. No file uploaded; metadata is available above.';
const NO_TOAST = '';
const ACTION = 'Open file metadata';
const COPY = {
intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.',
note: 'Your file stays in this browser. Choosing it opens its metadata.',
note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.',
chooser: 'Choose an image or video for a local preview',
multiple: 'Drop one file at a time.',
directory: 'Folders cannot be inspected. Choose one image or video file.',
@@ -54,24 +53,11 @@ async function choose(page, file) {
await chooser(page).setInputFiles(file);
}
/** D155: choosing a file opens the viewer on it with its details; waits for that. */
async function openViewer(page) {
await expect(viewer(page)).toBeVisible();
await expect(rail(page).locator('.panel-heading')).toBeVisible();
}
/** After the viewer was closed, the heading action (D60) opens it again on the current selection. */
async function reopenViewer(page) {
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
await action(page).click();
await openViewer(page);
}
/** Waits for the viewer a selection opened (D155) and closes it, back on the page. */
async function closeOpened(page) {
await openViewer(page);
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
await expect(viewer(page)).toBeVisible();
await expect(rail(page).locator('.panel-heading')).toBeVisible();
}
/** Dispatches dragover + drop on `selector` with Files built in the page (and optional string items). */
@@ -166,8 +152,7 @@ test('chooser opens the shared full inspector without a second read', async ({ p
const box = await image.evaluate(node => ({ height: node.getBoundingClientRect().height, fit: getComputedStyle(node).objectFit, margin: getComputedStyle(node).marginTop }));
expect(box.height).toBeLessThanOrEqual(400);
expect(box).toMatchObject({ fit: 'contain', margin: '20px' });
// D155: the open viewer is the confirmation, so no toast.
await expect(page.locator('#workspace-toast')).toHaveText(NO_TOAST);
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
await expect(action(page)).not.toHaveAttribute('aria-describedby', /.*/);
@@ -210,120 +195,18 @@ test('chooser opens the shared full inspector without a second read', async ({ p
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
// D155: focus returns to the chooser that opened it.
await expect(chooser(page)).toBeFocused();
await expect(action(page)).toBeFocused();
// Opening the viewer again shows the same record without reading the File a second time.
await page.evaluate(() => {
window.__slices = 0;
const slice = Blob.prototype.slice;
Blob.prototype.slice = function (...args) { if (this.name === 'f02.png') window.__slices++; return slice.apply(this, args); };
});
await reopenViewer(page);
await openViewer(page);
expect(await railContent(rail(page))).toEqual(served);
expect(await page.evaluate(() => window.__slices)).toBe(0);
});
// #448 (D155): choosing or dropping a file opens the shared viewer with its details showing, in one step. Closing it
// returns to the page with the file still chosen and focus on the chooser, ready for the next file.
test('D155: choosing a file opens the viewer with its details in one click', async ({ page }) => {
await openInspect(page);
await choose(page, parityPng('f02.png'));
await expect(viewer(page)).toBeVisible();
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
await expect(rail(page)).toBeVisible();
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur');
await expect(viewer(page).getByRole('button', { name: /^(Show|Hide) details$/ })).toHaveCount(0);
// The open viewer is the confirmation: no toast says the metadata is "available above".
await expect(page.locator('#workspace-toast')).toHaveText('');
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
await expect(chooser(page)).toBeFocused();
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
// A dropped second file replaces the first and opens at once.
await drop(page, '.file-drop', { files: [sendablePng()] });
await expect(viewer(page)).toBeVisible();
await expect(viewer(page).locator('#viewer-title')).toHaveText('send-me.png');
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
await expect(chooser(page)).toBeFocused();
// The same file again is a new selection and opens again; the heading action reopens the current one.
expect(await chooser(page).evaluate(node => { node.dispatchEvent(new MouseEvent('click')); return node.value; })).toBe('');
await choose(page, parityPng('f02.png'));
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
await page.keyboard.press('Escape');
await action(page).click();
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
await expect(rail(page)).toBeVisible();
await page.keyboard.press('Escape');
await expect(action(page)).toBeFocused();
});
test('D155: Inspect shows the details even when the viewer collapses them by default (amends D97)', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ sidebarCollapsedByDefault: true })));
await openInspect(page);
await choose(page, parityPng('f02.png'));
await expect(viewer(page)).toBeVisible();
await expect(rail(page)).toBeVisible();
const toggle = viewer(page).getByRole('button', { name: 'Hide details', exact: true });
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await toggle.click();
await expect(rail(page)).toBeHidden();
await page.keyboard.press('Escape');
await action(page).click();
await expect(rail(page)).toBeVisible();
await expect(viewer(page).getByRole('button', { name: 'Hide details', exact: true })).toHaveAttribute('aria-expanded', 'true');
});
test('D155: an unreadable file stays on the page with its alert', async ({ page }) => {
await page.addInitScript(() => {
const slice = Blob.prototype.slice;
Blob.prototype.slice = function (...args) {
if (this.name === 'gone.png') return { arrayBuffer: () => Promise.reject(new DOMException('The file is gone', 'NotReadableError')) };
return slice.apply(this, args);
};
});
await openInspect(page);
await choose(page, parityPng('f02.png', 'gone.png'));
await expect(status(page)).toHaveText('Embedded metadata could not be read.');
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
await expect(viewer(page)).toHaveCount(0);
});
test('D155: an AVI keeps its note and toast and does not open the viewer', async ({ page }) => {
await openInspect(page);
// AVI and WMV metadata is never read and the browser cannot play them, so the page keeps the selection's note.
const avi = { name: 'clip.avi', mimeType: 'video/x-msvideo', buffer: Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('AVI LIST'), Buffer.alloc(64)]) };
await choose(page, avi);
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
await expect(viewer(page)).toHaveCount(0);
await reopenViewer(page);
await expect(viewer(page).locator('#viewer-title')).toHaveText('clip.avi');
});
test('D155: a selection that lands while another dialog is open leaves the viewer closed', async ({ page }) => {
await openInspect(page);
await page.evaluate(() => { const other = document.createElement('dialog'); other.id = 'other-dialog'; other.textContent = 'Other'; document.body.append(other); other.showModal(); });
await choose(page, parityPng('f02.png'));
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
await expect(viewer(page)).toHaveCount(0);
await page.evaluate(() => document.querySelector('#other-dialog').close());
await reopenViewer(page);
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
});
test('D155: at 390px the opened details are reachable below the media', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openInspect(page);
await choose(page, parityPng('f02.png'));
await expect(viewer(page)).toBeVisible();
const facts = rail(page).locator('.meta-grid').first();
await facts.scrollIntoViewIfNeeded();
await expect(facts).toBeInViewport();
expect(await viewer(page).evaluate(node => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
});
// PR L3 Task 10: local roles are the server inspector's (local-resolver.mjs), so for every parity PNG the rail shows the
// prompts, their status lines and the facts Images shows for the same bytes in the output folder.
const PARITY_PNGS = ['f01b', 'f02', 'f03', 'f06a', 'f06b', 'f06c', 'f06d', 'f06e', 'f07a', 'f07b', 'f08', 'f09', 'f10', 'f11', 'f12',
@@ -377,9 +260,7 @@ test('drop accepts exactly one File and never navigates', async ({ page }) => {
await expect(preview(page)).toBeEmpty();
expect(await drop(page, '.file-drop', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true });
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
// D155: the drop opened the viewer; drops are ignored while it is open (D64), so close it first.
await closeOpened(page);
await expect(page.locator('#workspace-toast')).toHaveText(NO_TOAST);
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
// The chooser names the dropped file, never the one it replaced and never "No file chosen".
expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']);
const first = await preview(page).locator('img').getAttribute('src');
@@ -394,7 +275,7 @@ test('drop accepts exactly one File and never navigates', async ({ page }) => {
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
}
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
await reopenViewer(page);
await openViewer(page);
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
await expect(page).toHaveURL(/#\/inspect$/);
});
@@ -404,7 +285,6 @@ test('cancel and same-file reselect', async ({ page }) => {
await openInspect(page);
await choose(page, parityPng('f02.png'));
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
await closeOpened(page);
const first = await preview(page).locator('img').getAttribute('src');
// The chooser still names the chosen file, as in the concept; a cancel (no files) keeps the page as it is.
expect(await chooser(page).evaluate(node => node.files[0]?.name)).toBe('f02.png');
@@ -428,7 +308,6 @@ test('cancel and same-file reselect', async ({ page }) => {
const state = await urls(page);
expect(state.created).toHaveLength(2);
expect(state.revoked).toEqual([first]);
// The new selection opened the viewer again (D155).
await openViewer(page);
await expect(viewer(page).locator('.media-position')).toHaveText('1 of 1 loaded: f02.png');
});
@@ -458,7 +337,6 @@ test('preview does not imply metadata support', async ({ page }) => {
// Text, markup, SVG and a playlist under media names are unsupported and never get a URL.
const before = (await urls(page)).created.length;
for (const file of disguised()) {
// Unsupported: nothing opens (D155).
await choose(page, file);
await expect(status(page)).toHaveText(COPY.unsupported);
await expect(status(page)).toHaveAttribute('role', 'alert');
@@ -482,7 +360,6 @@ test('local video plays inline and in the viewer with real tags', async ({ page,
await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7');
await expect(viewer(page).locator('#viewer-stage video')).toHaveCount(1);
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
await choose(page, tinyWebm());
await expect(preview(page).locator('video.local-file-preview')).toBeVisible();
await openViewer(page);
@@ -509,9 +386,6 @@ test('the toast and a rejected drop never contradict the status line', async ({
// concept's toast interval.
await choose(page, undecodableMp4());
const failed = status(page).filter({ hasText: COPY.previewFailed });
// D155: the selection opened the viewer; its media failed there too, and the page's alert waits under it.
await expect(failed).toHaveCount(1);
await closeOpened(page);
await expect(failed).toHaveCount(1);
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
const rejected = status(page).filter({ hasText: COPY.multiple });
@@ -568,8 +442,7 @@ test('URLs and media end with their owning session', async ({ page }) => {
let state = await urls(page);
expect(state.revoked).toEqual([state.created[0]]);
expect(await page.evaluate(() => window.__loads)).toBeGreaterThanOrEqual(1);
// Route change, from the page (D155: the selection opened the viewer, which makes the sidebar inert).
await closeOpened(page);
// Route change.
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Overview', exact: true }).click();
await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
state = await urls(page);
+19 -57
View File
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-comparison.mjs';
@@ -9,9 +9,8 @@ import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-c
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
const workspace='/prompt_manager/workspace';
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
// D153 (#447): no Rating field; the fixture's positive stays stored with rating 4 through every workspace save.
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Notes'];
const UNAPPLIED='Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.';
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Rating','Notes'];
const RATINGS=[['5','5 / 5'],['4','4 / 5'],['3','3 / 5'],['2','2 / 5'],['1','1 / 5'],['0','Unrated']];
const editorDialog=page=>page.locator('dialog.library-editor');
test.beforeEach(async({page,request,browserName})=>{
@@ -66,8 +65,9 @@ test('edit recipe metadata shows the concept fields with the selected saved nega
await expect(dialog.getByRole('region')).toHaveCount(0);
await expect(field(dialog,'Category')).toHaveValue('Portraits');
await expect(field(dialog,'Tags, separated by commas')).toHaveValue('studio, window light');
await expect(field(dialog,'Rating')).toHaveValue('4');
await expect(field(dialog,'Notes')).toHaveValue(f.metadata.notes);
await expect(dialog.getByLabel('Rating')).toHaveCount(0);await expect(dialog.locator('select, #recipe-rating')).toHaveCount(0);
expect(await field(dialog,'Rating').locator('option').evaluateAll(nodes=>nodes.map(node=>[node.value,node.textContent]))).toEqual(RATINGS);
expect(await field(dialog,'Category').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['Portraits','Sketches']);
expect(await field(dialog,'Tags, separated by commas').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['draft,','studio,','window light,']);
await expect(dialog.locator('[placeholder]')).toHaveCount(0);
@@ -82,20 +82,19 @@ test('editor modes persist through reload with selected saved negative',async({p
const f=page.fixture;await mountEditor(page);
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Title').fill('Edited portrait');await field(dialog,'Category').fill('');
await field(dialog,'Tags, separated by commas').fill('studio, moody');
await field(dialog,'Tags, separated by commas').fill('studio, moody');await field(dialog,'Rating').selectOption('0');
await field(dialog,'Notes').fill('Edited notes');await button(dialog,'Save').click();
await expect(dialog).toHaveCount(0);
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/library/${f.positive_id}`]);
expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],notes:'Edited notes'});
expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],rating:null,notes:'Edited notes'});
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
const saved=(await detail(request,f.positive_id)).positive;
// D153: the edit never sends a rating, so the stored 4 stays.
expect(f.metadata.rating).toBe(4);expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:4,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']);
expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:null,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']);
await page.reload();await mountEditor(page);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue('Edited portrait');await expect(field(dialog,'Category')).toHaveValue('');
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
await expect(field(dialog,'Rating')).toHaveValue('0');await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
await button(dialog,'Create text revision').click();
await expect(dialog.getByRole('heading',{name:'Create text revision',exact:true})).toBeVisible();
await expect(editorDialog(page)).toHaveCount(1);expect(await dialog.evaluate(node=>node.open&&node.matches(':modal'))).toBe(true);
@@ -107,15 +106,12 @@ test('editor modes persist through reload with selected saved negative',async({p
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
const create=page.writes.at(-1);expect(`${create.method} ${create.url}`).toBe('POST /prompt_manager/library');
expect(create.body).toMatchObject({positive_text:'Editor portrait, revised dusk light',negative_text:f.selected_negative,
parent_metadata:{title:'Edited portrait',category:'Uncategorized',notes:'Edited notes'}});
expect(Object.hasOwn(create.body.parent_metadata,'rating'),'D153: a revision sends no rating').toBe(false);
parent_metadata:{title:'Edited portrait',category:'Uncategorized',rating:null,notes:'Edited notes'}});
expect([...create.body.parent_metadata.tags].sort()).toEqual(['moody','studio']);
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
const revision=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
expect(revision).toMatchObject({positive_created:true,pair_created:true});expect(revision.positive.id).not.toBe(f.positive_id);
// D153: the revision is a new, unrated positive; the revised prompt keeps its stored rating.
expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized',rating:null});
expect((await detail(request,f.positive_id)).positive.rating).toBe(4);
expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized'});
dialog=await openEditor(page,{mode:'new'});await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
expect(await actionNames(dialog)).toEqual(ACTIONS.new);
@@ -124,9 +120,9 @@ test('editor modes persist through reload with selected saved negative',async({p
await expect(field(dialog,'Title')).toBeFocused();expect(await field(dialog,'Title').evaluate(node=>node.validity.valueMissing)).toBe(true);
expect(page.writes).toHaveLength(before);
await field(dialog,'Title').fill('Fresh idea');await field(dialog,'Positive prompt').fill('A fresh lighthouse at dawn');
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
await field(dialog,'Rating').selectOption('5');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
expect(page.writes.at(-1).body).toEqual({expected_epoch:expect.any(String),positive_text:'A fresh lighthouse at dawn',negative_text:'',
parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],notes:''}});
parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],rating:5,notes:''}});
dialog=await openEditor(page,{mode:'negative',positiveId:f.positive_id});
await expect(dialog.getByRole('heading',{name:'Add a negative variant',exact:true})).toBeVisible();await ready(dialog);
@@ -138,7 +134,6 @@ test('editor modes persist through reload with selected saved negative',async({p
const added=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
expect(added).toMatchObject({positive_created:false,pair_created:true,pair:{negative_text:'grain, dust'}});
await expect(page.locator('#editor-trigger')).toBeFocused();
expect((await detail(request,f.positive_id)).positive.rating,'D153: adding a negative keeps the stored rating').toBe(4);
});
test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{
@@ -459,37 +454,6 @@ async function leaveAndReturn(page,id) {
}
const laneLabel=(page,text)=>variantsPanel(page).locator('.variant-lane').filter({hasText:text});
// #415: a restore the page sees finish while New prompt is open keeps the dialog and its entries; the Library is shown
// again from the restored data only once the dialog has closed.
test('a restore seen finishing keeps New prompt open and reloads the Library after it closes',async({page,request})=>{
const f=page.fixture;const snapshot=await request.get('/__test__/library-editor/snapshot');await expect(snapshot).toBeOK();
const buffer=await snapshot.body();
const current=await detail(request,f.positive_id,'active');
await expect(await request.patch(`/prompt_manager/library/${f.positive_id}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{title:'Retitled elsewhere'}}})).toBeOK();
const restoreRun=await followRestore(page);
await page.goto(`${workspace}#/library`);const card=page.locator(`article[data-prompt-id="${f.positive_id}"]`);
await expect(card).toContainText('Retitled elsewhere');
await expect(page.locator('#activity-pill')).toHaveAttribute('href',SEEN_RESTORE_PILL);
await headingActions(page).getByRole('button',{name:'+ New prompt',exact:true}).click();
const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
await field(dialog,'Title').fill('Typed before restore');await field(dialog,'Positive prompt').fill('A prompt typed while storage was restored');
await expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer}}})).toBeOK();
page.writes.length=0;
await restoreRun.finish();
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
await expect(dialog).toBeVisible();
await expect(field(dialog,'Title')).toHaveValue('Typed before restore');
await expect(field(dialog,'Positive prompt')).toHaveValue('A prompt typed while storage was restored');
await expect(card).toContainText('Retitled elsewhere');
await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.');
await expect(button(dialog,'Save')).toBeDisabled();expect(page.writes).toHaveLength(1);
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
await expect(card).not.toContainText('Retitled elsewhere');await expect(card).toContainText('Editor portrait');
await expect(page.locator('#workspace-main h1')).toBeFocused();
expect(page.writes).toHaveLength(1);
});
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
const actions=headingActions(page).locator('button');
@@ -809,20 +773,20 @@ test('archived reuse is reported, shown under Show archived and selected read-on
await uniqueDotNames(page);
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(f.archived_negative);
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Notes').fill(f.metadata.notes);
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText(message);
await expect(variantsPanel(page).locator('.library-variant-archived .variant-dot')).toHaveAttribute('aria-pressed','true');
expect(page.writes.every(write=>write.method==='POST')).toBe(true);
});
test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page,request})=>{
test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page})=>{
const f=page.fixture;await page.goto(`${workspace}#/library`);
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill('A different title');await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('brand new negative');
await field(dialog,'Notes').fill('Draft notes');await button(dialog,'Save').click();
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText(UNAPPLIED);
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue('A different title');await expect(field(dialog,'Notes')).toHaveValue('Draft notes');
@@ -830,8 +794,6 @@ test('reuse that leaves parent metadata unapplied discloses it and keeps the dra
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click();
expect(page.writes).toHaveLength(1);
// D153: neither the reuse nor the kept draft touches the stored rating.
expect((await detail(request,f.positive_id)).positive.rating).toBe(4);
});
test('reuse with the stored parent metadata entered does not claim it was not applied',async({page})=>{
@@ -839,7 +801,7 @@ test('reuse with the stored parent metadata entered does not claim it was not ap
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('another brand new negative');
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));
await field(dialog,'Notes').fill(f.metadata.notes);
await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
@@ -855,13 +817,13 @@ test('reuse of an uncategorized prompt with the category left blank is not unapp
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(negative);
await field(dialog,'Category').fill(category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));
await field(dialog,'Notes').fill(f.metadata.notes);
await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
};
await reuse('','blank category negative');
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
await reuse('Landscapes','different category negative');
await expect(toast(page)).toHaveText(UNAPPLIED);
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library','POST /prompt_manager/library']);
});
@@ -1,143 +0,0 @@
// #444 (D150): the Library's output-folder filter and multi-tag OR in "Refine this view", against the real server, and
// the Refine dialog against the concept's Filters dialog (app.js:1345-1352).
import {writeFile} from 'node:fs/promises';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {inventoryOf,diffInventory} from './library-editor-canonical-comparison.mjs';
const route='/prompt_manager/workspace#/library';
const concept='/__test__/canonical/workspace';
const cards=page=>page.locator('article[data-prompt-id]');
const card=(page,text)=>cards(page).filter({has:page.getByText(text,{exact:true})});
const toolbar=page=>page.locator('#workspace-main search.toolbar');
const filtersButton=page=>toolbar(page).getByRole('button',{name:/^Filters(?: · \d)?$/});
const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true});
const folder=page=>refine(page).getByRole('combobox',{name:'Folder',exact:true});
const tags=page=>refine(page).getByRole('textbox',{name:'Tags',exact:true});
const match=page=>refine(page).getByRole('combobox',{name:'Tag match',exact:true});
const apply=page=>refine(page).getByRole('button',{name:'Apply filters',exact:true});
const caption=page=>page.locator('#workspace-main .section-caption > span').first();
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK();
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
async function open(page) {
await page.goto(route);await expect(caption(page)).toHaveText(/^3 positive prompts · /);
}
/** Open Refine and wait for the folder list it reads on opening. */
async function openRefine(page) {
const folders=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/library/options'&&new URL(item.url()).searchParams.get('include')==='folders');
await filtersButton(page).click();await expect(refine(page)).toBeVisible();await folders;
}
test('Refine offers Folder, Tags and Tag match; a folder narrows the shelf to prompts with images there',async({page})=>{
await open(page);await openRefine(page);
await expect(refine(page).locator('.field label')).toHaveText(['Folder','Tags','Tag match']);
// Both roots hold a "shared" folder; the root's own images name no folder.
await expect(folder(page).locator('option')).toHaveText(['All output folders','shared']);
await expect(match(page).locator('option')).toHaveText(['All of these tags','Any of these tags','Prompts without tags']);
await folder(page).selectOption('shared');await apply(page).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);
await expect(cards(page)).toHaveCount(1);await expect(card(page,'Cat portrait')).toHaveCount(1);
await expect(filtersButton(page)).toHaveText('Filters · 1');
await openRefine(page);await expect(folder(page)).toHaveValue('shared');
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();
await expect(caption(page)).toHaveText(/^3 positive prompts · /);await expect(filtersButton(page)).toHaveText('Filters');
});
test('Tag match Any shows every prompt carrying one of the tags; changing only the match applies it',async({page})=>{
await open(page);await openRefine(page);
await tags(page).fill('cats, dogs');await apply(page).click();
await expect(caption(page)).toHaveText(/^0 positive prompts · /);await expect(filtersButton(page)).toHaveText('Filters · 1');
await openRefine(page);await match(page).selectOption('any');await apply(page).click();
await expect(caption(page)).toHaveText(/^2 positive prompts · /);
await expect(cards(page)).toHaveCount(2);await expect(card(page,'Cat portrait')).toHaveCount(1);await expect(card(page,'Dog landscape')).toHaveCount(1);
await expect(filtersButton(page)).toHaveText('Filters · 2');
});
test('a saved view keeps its folder and tag match',async({page})=>{
await open(page);await openRefine(page);
await folder(page).selectOption('shared');await tags(page).fill('cats, dogs');await match(page).selectOption('any');await apply(page).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);
await toolbar(page).getByRole('button',{name:'Save view',exact:true}).click();
const save=page.getByRole('dialog',{name:'Save this view',exact:true});
await save.getByRole('textbox',{name:'View name',exact:true}).fill('Shared cats or dogs');
const created=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/saved-views'&&item.request().method()==='POST');
await save.getByRole('button',{name:'Save',exact:true}).click();
const body=(await created).request().postDataJSON();
expect(body.state).toMatchObject({folder:'shared',tag_mode:'any',tags:['cats','dogs']});
await page.goto('about:blank');await open(page);
await page.getByRole('navigation',{name:'Saved views',exact:true}).getByRole('link',{name:'Shared cats or dogs',exact:true}).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);await expect(filtersButton(page)).toHaveText('Filters · 3');
});
test('a chosen folder missing from the folder list stays listed and chosen in Refine',async({page})=>{
await open(page);await openRefine(page);await folder(page).selectOption('shared');await apply(page).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);
// The folder has no image any more as far as the list knows (or the read failed): Apply must not drop it.
await page.route('**/prompt_manager/library/options?include=folders',route=>route.fulfill({json:{success:true,library_epoch:'x',categories:[],folders:[]}}));
await openRefine(page);
await expect(folder(page).locator('option')).toHaveText(['All output folders','shared']);await expect(folder(page)).toHaveValue('shared');
await apply(page).click();await expect(caption(page)).toHaveText(/^1 positive prompt · /);await expect(filtersButton(page)).toHaveText('Filters · 1');
});
// Live B1: the server's flat list sorts full paths as text ("-" and "_" before "/"); Refine shows each folder directly
// under its own parent and keeps case variants apart.
test('the Folder list nests each folder under its exact parent',async({page})=>{
const folders=['CCC','CCC/mabes9sans-dataset','CCC/mabes9sans-dataset-v1','CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN',
'FLUX','FLUX/2025-12-11','flux','flux/images','flux-klein','kiko','kiko-v6','kiko/char'].sort();
await page.route('**/prompt_manager/library/options?include=folders',route=>route.fulfill({json:{success:true,library_epoch:'x',categories:[],folders}}));
await open(page);await openRefine(page);
const I='\u00a0\u00a0';
await expect(folder(page).locator('option')).toHaveText(['All output folders','CCC',`${I}mabes9sans-dataset`,`${I}${I}mabes9sans-dataset_IMG_GEN`,
`${I}mabes9sans-dataset-v1`,'FLUX',`${I}2025-12-11`,'flux',`${I}images`,'flux-klein','kiko',`${I}char`,'kiko-v6']);
expect(await folder(page).locator('option').evaluateAll(nodes=>nodes.map(node=>node.value))).toEqual(['','CCC','CCC/mabes9sans-dataset',
'CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN','CCC/mabes9sans-dataset-v1','FLUX','FLUX/2025-12-11','flux','flux/images','flux-klein',
'kiko','kiko/char','kiko-v6']);
});
test('an older server that ignores the folder shows an error with Retry, never the whole shelf',async({page})=>{
await open(page);
await page.route('**/prompt_manager/library?*',async route=>{
const response=await route.fetch();const body=await response.json();delete body.folder;delete body.tag_mode;
await route.fulfill({response,json:body});
});
await openRefine(page);await folder(page).selectOption('shared');await apply(page).click();
await expect(page.locator('#workspace-main').getByText('Prompt library could not be loaded. Retry.')).toBeVisible();
await expect(cards(page)).toHaveCount(0);
});
// D150: the Refine dialog is the concept's Filters dialog: its Folder select and Tags input, plus Tag match drawn as the
// concept's own select; the concept's "Prompt evidence" (no data yet) and its prototype note are not shown.
function mapFiltersDialog(reference,{folders,matchOptions}) {
const [conceptFolder,,conceptTags]=reference.fields;
return {...reference,
fields:[{...conceptFolder,options:folders},{...conceptTags},{...conceptFolder,label:'Tag match',options:matchOptions}],
small:[],
// Production's Apply reads "Apply filters" (Images and Library share it, #249).
actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Apply tag filter'?{...item,text:'Apply filters'}:item)}};
}
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`Refine dialog matches the concept Filters dialog ${width} ${theme} (D150)`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
await page.goto('about:blank');await page.goto(`${concept}#library`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await page.locator('#main').getByRole('button',{name:'Filters',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
const reference=await page.locator('#modal').evaluate(inventoryOf);
await captureCanonicalState(page,testInfo,`concept-filters-${width}-${theme}`,'#modal');
await open(page);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openRefine(page);
await expect(folder(page).locator('option')).toHaveCount(2);
const actual=await refine(page).evaluate(inventoryOf);
await captureCanonicalState(page,testInfo,`production-refine-${width}-${theme}`,'dialog[open]');
const expected=mapFiltersDialog(reference,{folders:[['','All output folders'],['shared','shared']],matchOptions:[['all','All of these tags'],['any','Any of these tags'],['untagged','Prompts without tags']]});
const path=testInfo.outputPath('refine-inventory.json');await writeFile(path,JSON.stringify({reference,actual},null,2));
await testInfo.attach('refine-inventory',{path,contentType:'application/json'});
expect(reference.fields.map(item=>item.label)).toEqual(['Folder','Prompt evidence','Tags']);
expect(diffInventory(expected,actual)).toEqual([]);
await refine(page).getByRole('button',{name:'Close dialog',exact:true}).click();
await openRefine(page);await folder(page).selectOption('shared');await tags(page).fill('cats, dogs');await match(page).selectOption('any');await apply(page).click();
await expect(caption(page)).toHaveText(/^1 positive prompt · /);await page.mouse.move(0,0);
await captureCanonicalState(page,testInfo,`production-library-folder-any-${width}-${theme}`,'#workspace-main');
});
@@ -1,294 +0,0 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
// #437 D148: select prompts on the Library shelf and set their category in one step (the legacy admin's Set Category).
const route='/prompt_manager/workspace#/library';
const cards=page=>page.locator('article[data-prompt-id]');
const card=(page,id)=>page.locator(`article[data-prompt-id="${id}"]`);
const box=(page,id)=>card(page,id).locator('.image-overlays input[type="checkbox"]');
const bar=page=>page.locator('#library-bulkbar > .bulkbar');
const dialog=page=>page.getByRole('dialog',{name:'Categorize selected prompts',exact:true});
const toast=page=>page.locator('.toast');
const heading=page=>page.locator('#workspace-main h1');
const tagsDialog=page=>page.getByRole('dialog',{name:'Tag selected prompts',exact:true});
const chips=(page,id)=>card(page,id).locator('.tags > .chip');
async function addTags(page,value) {
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
await expect(tagsDialog(page)).toBeVisible();
await tagsDialog(page).locator('#action-input').fill(value);
await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click();
}
async function shelf(request) {
const body=await(await request.get('/prompt_manager/library?limit=100')).json();
return body.items;
}
/** Every POST to the bulk category route, with its body. */
function recordWrites(page) {
const writes=[];
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/category')writes.push(item.postDataJSON());});
return writes;
}
/** The first mounted card's index once two frames in a row agree (the shelf's window follows scrolling a frame later). */
async function stableFirstIndex(page) {
return page.evaluate(()=>new Promise(resolve=>{
const first=()=>document.querySelector('article[data-prompt-id]')?.dataset.index;
let previous=first();
const check=()=>requestAnimationFrame(()=>requestAnimationFrame(()=>{const now=first();if(now===previous)resolve(Number(now));else{previous=now;check();}}));
check();
}));
}
async function setCategory(page,value) {
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
await expect(dialog(page)).toBeVisible();
await dialog(page).locator('#action-input').fill(value);
await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
}
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK();
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
test('Set category writes the selected prompts on the server, reloads the shelf and clears the selection',async({page,request})=>{
const items=await shelf(request);const [first,second,third]=items;
const writes=recordWrites(page);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
// The checkbox is named after the card's title, as the Images overlay names its file.
await expect(box(page,first.id)).toHaveAccessibleName(new RegExp(`^Select `));
await box(page,first.id).check();await expect(bar(page).locator('strong')).toHaveText('1 selected');
await box(page,second.id).check();await expect(bar(page).locator('strong')).toHaveText('2 selected');
// Two of three loaded are checked, so Select all N loaded follows the count (D151, #444 ruling).
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Clear']);
await setCategory(page,' Studio set ');
await expect(toast(page)).toHaveText('Category “Studio set” set on 2 prompts.');
await expect(dialog(page)).toHaveCount(0);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
for(const item of [first,second]) await expect(card(page,item.id).locator('.card-title .chip')).toHaveText('Studio set');
await expect(card(page,third.id).locator('.card-title .chip')).toHaveText(third.category || 'Uncategorized');
await expect(box(page,first.id)).not.toBeChecked();
const after=await shelf(request);
expect(after.filter(item=>item.category==='Studio set').map(item=>item.id).sort()).toEqual([first.id,second.id].sort());
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
expect(writes).toEqual([{prompt_ids:[first.id,second.id],category:'Studio set',expected_epoch:epoch}]);
// The toolbar's category list now offers the new value, and so does the next dialog.
await expect(page.locator('#category-filter option',{hasText:'Studio set'})).toHaveCount(1);
await box(page,third.id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
await expect(dialog(page).locator('datalist option[value="Studio set"]')).toHaveCount(1);
await dialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
expect(writes).toHaveLength(1);
});
test('a blank or control-character category keeps the dialog open; Clear and Escape clear the selection',async({page,request})=>{
const items=await shelf(request);const writes=recordWrites(page);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
const field=dialog(page).locator('#action-input');
await field.fill(' ');await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
await expect(dialog(page)).toBeVisible();await expect(field).toBeFocused();
await field.fill('bad\tvalue');await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
await expect(dialog(page)).toBeVisible();
expect(await field.evaluate(node=>node.validationMessage)).toBe('A category cannot contain control characters.');
await dialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
expect(writes).toHaveLength(0);
await bar(page).getByRole('button',{name:'Clear',exact:true}).click();
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(heading(page)).toBeFocused();
await expect(box(page,items[0].id)).not.toBeChecked();
await box(page,items[1].id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).focus();await page.keyboard.press('Escape');
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(heading(page)).toBeFocused();
// A filter change replaces the shelf and clears the selection too.
await box(page,items[1].id).check();await expect(bar(page)).toBeVisible();
await page.locator('#search').fill('portrait');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
});
test('Shift+click selects a range across windowed cards, Tab reaches the checkbox at the window edge, and a return keeps the fence',async({page,request,isMobile})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}})).toBeOK();
if(!isMobile)await page.setViewportSize({width:1600,height:1100});
const writes=recordWrites(page);
await page.goto(route);await expect(cards(page).first()).toBeVisible();
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(let index=0;index<3;index++) {
await expect(more).toHaveAttribute('aria-disabled','false');
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;
}
await expect(more).toHaveAttribute('aria-disabled','false');
const firstId=Number(await cards(page).first().getAttribute('data-prompt-id'));
await box(page,firstId).check();
// Windowed: fewer cards are mounted than are loaded.
expect(await cards(page).count()).toBeLessThan(160);
// Tab from the last mounted card's last control lands on the next (unmounted) card's checkbox.
const last=cards(page).last();const lastIndex=Number(await last.getAttribute('data-index'));
await last.locator('button').last().focus();await page.keyboard.press('Tab');
const next=page.locator(`article[data-index="${lastIndex+1}"]`);
await expect(next.locator('.image-overlays input')).toBeFocused();
// Shift+click that card's checkbox: every card from the first to it is selected, mounted or not.
await next.locator('.image-overlays > label').click({modifiers:['Shift']});
await expect(bar(page).locator('strong')).toHaveText(`${lastIndex+2} selected`);
// Shift+Tab from a checkbox whose previous card is unmounted lands on that card's last control.
// Halfway down the loaded cards (short of the continuation, so no page loads), once the window stops moving.
await page.evaluate(()=>{const grid=document.querySelector('.library-grid');scrollTo(0,grid.getBoundingClientRect().top+scrollY+grid.offsetHeight/2);});
await expect.poll(()=>page.locator('article[data-index="0"]').count()).toBe(0);
const topIndex=await stableFirstIndex(page);
await page.locator(`article[data-index="${topIndex}"] .image-overlays input`).focus();await page.keyboard.press('Shift+Tab');
await expect(page.locator(`article[data-index="${topIndex-1}"] .card-footer .row-actions > button`).last()).toBeFocused();
// Leaving for a prompt's history and coming back restores the shelf without a read; its epoch still fences.
await page.locator(`article[data-index="${topIndex}"]`).getByRole('button',{name:'Explore history',exact:true}).click();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(cards(page).first()).toBeVisible();await expect(page.locator('#library-bulkbar')).toBeEmpty();
const visible=Number(await cards(page).first().getAttribute('data-prompt-id'));
await box(page,visible).check();await setCategory(page,'Returned');
await expect(toast(page)).toHaveText('Category “Returned” set on 1 prompt.');
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
expect(writes).toEqual([{prompt_ids:[visible],category:'Returned',expected_epoch:epoch}]);
});
test('a restore the page did not see refuses the write, changes nothing and reloads the shelf',async({page,request})=>{
await armUnseenRestores(page);
const items=await shelf(request);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
await expect(restore).toBeOK();
const reload=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&!url.searchParams.has('cursor');});
await setCategory(page,'After restore');
await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No category was changed.');
await reload;await expect(page.locator('#library-bulkbar')).toBeEmpty();
expect((await shelf(request)).filter(item=>item.category==='After restore')).toEqual([]);
// The reloaded shelf reads the new epoch: the next write goes through.
await box(page,items[0].id).check();await setCategory(page,'After reload');
await expect(toast(page)).toHaveText('Category “After reload” set on 1 prompt.');
});
test('an answer that may follow a committed write says so and reloads; a refused write keeps the selection',async({page,request})=>{
const items=await shelf(request);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
await page.route('**/prompt_manager/bulk/category',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'}),{times:1});
await setCategory(page,'Refused');
await expect(toast(page)).toHaveText('Category could not be set. Try again.');
await expect(bar(page).locator('strong')).toHaveText('1 selected');
let sent=false;
await page.route('**/prompt_manager/bulk/category',async route=>{sent=true;await route.fetch();await route.abort('failed');},{times:1});
await setCategory(page,'Unconfirmed');
await expect(toast(page)).toHaveText('The category change could not be confirmed. The Library was reloaded.');
expect(sent).toBe(true);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
await expect(card(page,items[0].id).locator('.card-title .chip')).toHaveText('Unconfirmed');
});
// #442: Add tags writes prompt tags (never image tags) on the selected prompts.
function recordTagWrites(page) {
const writes=[];
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/tags')writes.push(item.postDataJSON());});
return writes;
}
async function promptTags(request,id) {
return (await shelf(request)).find(item=>item.id===id).tags;
}
test('Add tags adds prompt tags to the selected prompts, suggests the vocabulary and reloads the shelf',async({page,request})=>{
const items=await shelf(request);const [first,second,third]=items;const writes=recordTagWrites(page);
const vocabulary=(await(await request.get('/prompt_manager/tags')).json()).tags;
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,first.id).check();await box(page,second.id).check();
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
await expect(tagsDialog(page).locator('datalist option')).toHaveCount(vocabulary.length);
await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(tagsDialog(page)).toHaveCount(0);
await addTags(page,' studio-lit , Night ,studio-lit');
await expect(toast(page)).toHaveText('Added “studio-lit, Night” to 2 prompts.');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
for(const item of [first,second]) {
await expect(chips(page,item.id).filter({hasText:'studio-lit'})).toHaveCount(1);
expect(await promptTags(request,item.id)).toEqual(expect.arrayContaining(['studio-lit','Night']));
}
expect(await promptTags(request,third.id)).not.toContain('studio-lit');
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
expect(writes).toEqual([{prompt_ids:[first.id,second.id],tags:['studio-lit','Night'],expected_epoch:epoch}]);
// A second add of the same names changes nothing and says so.
await box(page,first.id).check();await addTags(page,'Studio-Lit');
await expect(toast(page)).toHaveText('1 prompt already had “Studio-Lit”.');
});
test('the Add tags field states what it refuses, and a restore the page did not see adds nothing',async({page,request})=>{
await armUnseenRestores(page);
const items=await shelf(request);const writes=recordTagWrites(page);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
const field=tagsDialog(page).locator('#action-input');
for(const [value,message] of [[' , ','Enter at least one tag.'],['x'.repeat(51),'A tag can have at most 50 characters.'],
[Array.from({length:21},(_,i)=>`t${i}`).join(','),'Enter at most 20 tags.']]) {
await field.fill(value);await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click();
await expect(tagsDialog(page)).toBeVisible();
expect(await field.evaluate(node=>node.validationMessage)).toBe(message);
}
await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();
expect(writes).toHaveLength(0);
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
await expect(restore).toBeOK();
await addTags(page,'after-restore');
await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No tags were added.');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
expect((await(await request.get('/prompt_manager/tags')).json()).tags).not.toContain('after-restore');
});
// #444 ruling (D151): select every loaded prompt, as the legacy page's select-all checkbox did for its page.
test('Ctrl/Cmd+A and Select all N loaded select every loaded prompt, windowed ones included',async({page,request,isMobile})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}})).toBeOK();
if(!isMobile)await page.setViewportSize({width:1600,height:1100});
const writes=recordTagWrites(page);
await page.goto(route);await expect(cards(page).first()).toBeVisible();
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(let index=0;index<3;index++) {
await expect(more).toHaveAttribute('aria-disabled','false');
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;
}
await expect(more).toHaveAttribute('aria-disabled','false');
const loaded=160;expect(await cards(page).count()).toBeLessThan(loaded);
// In the search box Ctrl/Cmd+A keeps its text meaning: it selects the box's text, no prompt.
await page.locator('#search').focus();await page.keyboard.press('ControlOrMeta+a');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
// One checked card offers the rest; the offer selects every loaded prompt and focus moves to the first action.
const firstId=Number(await cards(page).first().getAttribute('data-prompt-id'));
await box(page,firstId).check();
const offer=bar(page).getByRole('button',{name:`Select all ${loaded} loaded`,exact:true});
await expect(bar(page).locator(':scope > *').nth(1)).toHaveText(`Select all ${loaded} loaded`);
await offer.click();
await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`);
await expect(offer).toHaveCount(0);
await expect(bar(page).getByRole('button',{name:'Add tags',exact:true})).toBeFocused();
// Another batch arrives unchecked: the offer comes back with the new N.
{
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;
}
await expect(bar(page).getByRole('button',{name:`Select all ${loaded+40} loaded`,exact:true})).toBeVisible();
await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`);
// Escape in the grid clears; Ctrl/Cmd+A in the grid selects them all again.
const title=cards(page).first().locator('.card-title > button');
await title.focus();await page.keyboard.press('Escape');
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(title).toBeFocused();
await page.keyboard.press('ControlOrMeta+a');
const all=loaded+40;
await expect(bar(page).locator('strong')).toHaveText(`${all} selected`);
// While the write runs, every bar action and the offer are aria-disabled.
await box(page,firstId).uncheck();
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route('**/prompt_manager/bulk/tags',async route=>{await held;await route.continue();},{times:1});
await addTags(page,'everyone');
for(const name of ['Add tags','Set category',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
release();
await expect(toast(page)).toHaveText(`Added “everyone” to ${all-1} prompts.`);
expect(writes).toHaveLength(1);expect(new Set(writes[0].prompt_ids).size).toBe(all-1);
});
+12 -47
View File
@@ -57,26 +57,6 @@ test('library groups reusable negatives under one positive without exposing nega
for(const item of rows.items) await expect(page.locator(`article[data-prompt-id="${item.id}"] .card-meta > span`).nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
});
// #464: a starved renderer (headless WebKit under CPU load, a background tab) can hold animation frames for seconds;
// a loaded page of prompts is shown without waiting for one.
test('a held animation frame does not hide a loaded page of prompts, the first or a later one',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:45}})).toBeOK();
const {total}=await(await request.get('/prompt_manager/library?limit=100')).json();
await page.addInitScript(()=>{
const request=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window);
const held=new Map(),real=new Map();let holding=true,next=0;
window.requestAnimationFrame=callback=>{const id=--next;if(holding)held.set(id,callback);else real.set(id,request(callback));return id;};
window.cancelAnimationFrame=id=>{held.delete(id);if(real.has(id))cancel(real.get(id));real.delete(id);};
window.__releaseAnimationFrames=()=>{holding=false;for(const [id,callback] of held)real.set(id,request(callback));held.clear();};
});
await page.goto(route);
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
await expect(more).toHaveAttribute('aria-disabled','false');await expect(cards(page)).toHaveCount(40);
await more.evaluate(node=>node.click());await expect(page.locator('.library-status')).toHaveText('You’re all caught up.');
await expect(cards(page)).toHaveCount(total);
await page.evaluate(()=>window.__releaseAnimationFrames());await nextFrame(page);
await expect(cards(page)).toHaveCount(total);await expect(page.locator('.library-row-spacer')).toHaveCount(0);
});
test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
@@ -155,8 +135,7 @@ test('Retry re-reads a failed category list with the shelf',async({page})=>{
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click();
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.fill(' ');
// Only the folder list Refine reads on opening (D150), never the shelf.
await page.waitForTimeout(400);expect(reads).toEqual(['/prompt_manager/library/options']);await expect(cards(page)).toHaveCount(3);
await page.waitForTimeout(400);expect(reads).toEqual([]);await expect(cards(page)).toHaveCount(3);
});
test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{
@@ -173,25 +152,13 @@ test('failed library reads retry while late search responses cannot overwrite ne
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape');
});
// D153 (#447): a Browse visit that still names the retired "Highest rated" sort opens the shelf on Recently added.
test('a Library visit with the retired rating sort opens on Recently added',async({page})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library')sorts.push(url.searchParams.get('sort'));});
await page.goto('/prompt_manager/workspace#/overview');await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(async()=>{const {browseLibrary}=await import('/prompt_manager/js/workspace/library.mjs');browseLibrary({sort:'rating'});});
await expect(cards(page)).toHaveCount(3);
await expect(page.getByRole('combobox',{name:'Sort results',exact:true})).toHaveValue('newest');
expect(sorts).toEqual(['newest']);
});
test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Save view, Filters.
const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1);
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']);
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
// D153 (#447): no "Highest rated".
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Oldest first']);
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']);
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
@@ -201,7 +168,7 @@ test('library toolbar matches the concept and tags live in Refine this view',asy
// D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted.
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
await expect(refine(page).locator('.field label')).toHaveText(['Folder','Tags','Tag match']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
await expect(refine(page).locator('.field label')).toHaveText(['Tags']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused();
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('');await expect(cards(page)).toHaveCount(3);
@@ -272,8 +239,7 @@ test('a sort change atomically discards old row geometry and renders changed par
} finally {await page.evaluate(()=>window.releaseLibraryFrames());}
await expect(cards(page)).toHaveCount(3);
const changed=page.locator(`article[data-prompt-id="${id}"]`);await expect(changed).toContainText('Revised dog landscape');
// D153: a stored rating of 5 still draws no stars.
await expect(changed.locator('.card-footer > span')).not.toContainText('★');await expect(changed.locator('.card-footer > span > *')).toHaveCount(0);
await expect(changed.locator('.card-footer .ca-visually-hidden')).toHaveText('Rated 5 out of 5');await expect(changed.locator('.card-footer > span > [aria-hidden="true"]')).toHaveText('★★★★★');
await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden();
});
@@ -479,8 +445,8 @@ test('Library cards clamp long prompts and keep real content inside the card',as
for(const item of data.items) {
const card=page.locator(`article[data-prompt-id="${item.id}"]`);
const footer=card.locator('.card-footer > span');
// D153: no stars or "Rated N out of 5", whatever the stored rating.
await expect(footer.locator('> *')).toHaveCount(0);
await expect(footer.locator('[aria-hidden="true"]')).toHaveText(item.rating?'★'.repeat(item.rating):[]);
await expect(footer.locator('.ca-visually-hidden')).toHaveText(item.rating?`Rated ${item.rating} out of 5`:[]);
await expect(card.locator('.card-content > .tags .chip')).toHaveText(item.tags);
await expect(card.locator('.card-meta > span').nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
}
@@ -632,7 +598,7 @@ async function patchShelf(page,patch) {
await route.fulfill({response,json:body});
});
}
/** The footer line as seen: everything but visually hidden text (D153: there is no rating sentence any more). */
/** The footer line as seen: everything but its visually hidden rating sentence. */
const shownText=node=>[...node.childNodes].filter(child=>!child.classList?.contains('ca-visually-hidden')).map(child=>child.textContent).join('');
const longPrompt=[`${'A luminous alpine observatory with layered glass terraces '.repeat(6)}`,'second paragraph keeps the full text',`final line ${'detail '.repeat(30)}end of prompt`].join('\n');
@@ -652,8 +618,7 @@ test('card matches the concept and never wraps the category mid-word',async({pag
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
// app.js renderLibrary a586: article.prompt-card > .card-art, .card-content, .card-footer.
await expect(card).toHaveClass('prompt-card');
// D148 (#437): the concept's Images selection overlay leads the card.
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['image-overlays','card-art','card-content','card-footer']);
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-art','card-content','card-footer']);
expect(await card.locator('.card-art').evaluate(node=>[...node.children].map(child=>child.tagName))).toEqual(['IMG','IMG']);
expect(await card.locator('.card-content').evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-title','prompt-inspector','card-meta','tags']);
expect(await card.locator('.card-title').evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['BUTTON.','SPAN.chip']);
@@ -668,13 +633,13 @@ test('card matches the concept and never wraps the category mid-word',async({pag
await expect(card.locator('.tags > .chip')).toHaveText(['cats','studio']);
const footer=card.locator('.card-footer');
expect(await footer.evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['SPAN.','DIV.row-actions']);
// D153 (#447): the stored rating 5 draws no stars; the footer reads only the last use.
await expect(footer.locator('> span > *')).toHaveCount(0);
expect(await footer.locator('> span').evaluate(shownText)).toBe('Used today');
await expect(footer.locator('> span > [aria-hidden="true"]')).toHaveText('★★★★★');
await expect(footer.locator('> span > .ca-visually-hidden')).toHaveText('Rated 5 out of 5');
expect(await footer.locator('> span').evaluate(shownText)).toBe('★★★★★ · Used today');
const actions=footer.locator('.row-actions > button');await expect(actions).toHaveCount(2);
await expect(actions.first()).toHaveClass('send icon-btn');await expect(actions.first()).toHaveAccessibleName('Send positive prompt to ComfyUI: Cat portrait');
await expect(actions.nth(1)).toHaveAccessibleName('Explore history');await expect(actions.nth(1).locator('svg')).toHaveCount(1);
// Never used: the used label alone; no negatives still reads as a count.
// Unrated and never used: the used label alone; no negatives still reads as a count.
const unrated=page.locator(`article[data-prompt-id="${dual.id}"]`);
expect(await unrated.locator('.card-footer > span').evaluate(shownText)).toBe('Not used yet');
await expect(unrated.locator('.card-footer > span > [aria-hidden="true"]')).toHaveCount(0);
+7 -33
View File
@@ -23,7 +23,7 @@ const heading = page => main(page).getByRole('heading', { level: 1 });
const saveDialog = page => page.getByRole('dialog', { name: 'Save this view', exact: true });
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
const toast = page => page.locator('#workspace-toast');
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' };
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
async function refine(page, values) {
await filtersButton(page).click();
@@ -73,8 +73,7 @@ test('Save view saves the Images toolbar and Refine filters, and its sidebar row
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
// The concept's sidebar group: SAVED VIEWS without its PROPOSED badge (D49), empty until a view is saved.
// D136: the COLLECTIONS label stays hidden while no collection is listed.
await expect(page.locator('aside.sidebar .sidebar-label:not([hidden])')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
await expect(page.locator('aside.sidebar .sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
await expect(viewRows(page)).toHaveCount(0);
await search(page).fill('dogs');
@@ -208,7 +207,7 @@ test('a view stored under older rules still lists and opens with its usable valu
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) });
expect(await listed(request)).toEqual([
{ id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } },
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }),
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [] } }),
]);
// A state that is no JSON object is never listed, and its link behaves like a missing view.
const corruptId = await raw({ page: 'images', name: 'Corrupt', state: '{not json' });
@@ -230,34 +229,9 @@ test('a view stored under older rules still lists and opens with its usable valu
await expect(promptCards(page)).toHaveCount(1);
});
// D153 (#447): the workspace no longer offers "Highest rated"; the API still stores such a view as it was saved.
test('a view saved under Highest rated opens on Recently added on Images and the Library', async ({ page, request }) => {
const images = await create(request, { page: 'images', name: 'Rated cats', state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'rating' } });
const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all', folder: '' } });
expect((await listed(request)).map(view => view.state.sort)).toEqual(['rating', 'rating']);
const listings = [];
page.on('request', item => {
const url = new URL(item.url());
if (['/prompt_manager/images/output', '/prompt_manager/library'].includes(url.pathname)) listings.push(url.searchParams.get('sort'));
});
await page.goto(`${workspace}#/saved/${images.id}`);
await expect(heading(page)).toHaveText('Rated cats');
await expect(sortBy(page)).toHaveValue('newest');
expect(await sortBy(page).locator('option').allTextContents()).not.toContain('Highest rated');
await expect(search(page)).toHaveValue('cat');
await expect(imageCards(page)).toHaveCount(1);
await savedNav(page).getByRole('link', { name: 'Rated dogs', exact: true }).click();
await expect(heading(page)).toHaveText('Rated dogs');
await expect(sortBy(page)).toHaveValue('newest');
await expect(search(page)).toHaveValue('dog');
await expect(promptCards(page)).toHaveCount(1);
expect(listings.length).toBeGreaterThan(1);
expect(listings).not.toContain('rating');
});
test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => {
const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
await page.goto(`${workspace}#/images`);
await search(page).fill('cat');
await expect(imageCards(page)).toHaveCount(1);
@@ -300,7 +274,7 @@ test('a toolbar change while sources load keeps the saved Source and Folder (#24
});
test('a saved view left while its list or rename is pending leaves the next page alone', async ({ page, request }) => {
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
let releaseList = () => {};
const holdList = async () => {
const held = new Promise(resolve => { releaseList = resolve; });
@@ -348,7 +322,7 @@ test('an unknown saved view opens Images, and a failed listing offers Retry', as
await expect(page).toHaveURL(/#\/images$/);
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Images');
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
let failing = true;
await page.route(`**${API}`, route => failing && route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' })
@@ -539,7 +513,7 @@ test('Edit renames an open view, and deletes it only after confirming inside the
});
test('a deleted Library view returns to the Library, and failed edits say so', async ({ page, request }) => {
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } });
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText('Cats');
const edit = main(page).locator('.heading-actions > button').first();
@@ -1,236 +0,0 @@
// #430: an open workspace page whose sources changed under it. A ComfyUI restart no longer changes any scope (the
// key is persisted, see tests/test_api_workspace_restart.py); what is left is a real source change. Renumbered
// sources (`/__test__/sources/reverse`) are found again: the Tag image dialog stays open on its file and works, and
// the panel keeps the file. A scope that cannot be mapped (`/__test__/scope-key/rotate`, a lost key) blocks Generate
// and Save visibly, says why beside them and offers Refresh. Both commands are undone by every reset.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
const images = '/prompt_manager/workspace#/images';
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png';
const SOURCE_CHANGED = 'The source changed. Refresh the image collection before tagging this file.';
const card = (page, path, root = 0) => page.locator(`article[data-image-key="${root}:${path}"]`);
const cards = page => page.locator('.gallery-body article[data-image-key]');
const tagButton = (page, path, root = 0) => card(page, path, root).getByRole('button', { name: `Tag or auto-tag ${path.split('/').pop()}`, exact: true });
const infoButton = (page, path, root = 0) => card(page, path, root).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
const primary = page => dialog(page).locator('.dialog-actions .ca-primary');
const notice = page => dialog(page).locator('.dialog-actions > .tag-action-notice');
const status = page => dialog(page).locator('#tag-generation-status');
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const toast = page => page.locator('#workspace-toast');
const answered = (page, suffix, method = 'POST') => page.waitForResponse(item => new URL(item.url()).pathname.endsWith(suffix) && item.request().method() === method);
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
page.staleErrors = [];
page.on('pageerror', error => page.staleErrors.push(error.message));
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
expect(page.staleErrors).toEqual([]);
// The next spec file starts on the standard sources and key.
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
});
async function openTag(page, path = FIRST, root = 0) {
const observed = answered(page, '/image-annotations/observe');
await tagButton(page, path, root).click();
await expect(dialog(page)).toBeVisible();
return observed;
}
async function savedTags(request, path, root) {
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
const item = listing.images.find(image => image.path === path && image.root_index === root);
const target = { root_index: root, root_scope: item.root_scope, path };
const observed = await (await request.post('/prompt_manager/image-annotations/observe', { data: { target } })).json();
await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);
return observed.tags;
}
test('renumbered sources under an open Tag dialog: it finds the file again, keeps the draft and Generate works', async ({ page, request }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
expect((await openTag(page)).status()).toBe(200);
await manual(page).fill('kept_draft');
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
// Generate is refused (the held scope names another index now); the page asks where the file is and retargets.
const refused = answered(page, '/image-annotations/suggest');
const rescoped = answered(page, '/images/rescope');
const observedAgain = answered(page, '/image-annotations/observe');
await generate(page).click();
expect((await refused).status()).toBe(409);
const mapped = await (await rescoped).json();
expect([mapped.image.root_index, mapped.image.path]).toEqual([1, FIRST]);
const observed = await observedAgain;
expect(observed.status()).toBe(200);
expect(observed.request().postDataJSON().target).toEqual({ root_index: 1, root_scope: mapped.image.root_scope, path: FIRST });
await expect(dialog(page)).toBeVisible();
await expect(notice(page)).toHaveCount(0);
await expect(manual(page)).toHaveValue('kept_draft');
await expect(dialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /\/image-annotations\/capture\//);
// The page listed again under the new numbering and kept the file selected.
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
await expect(card(page, FIRST, 0)).toHaveCount(0);
const suggested = answered(page, '/image-annotations/suggest');
await generate(page).click();
expect((await suggested).status()).toBe(200);
await expect(chips(page).first()).toBeVisible();
const saved = answered(page, '/image-annotations/tags', 'PUT');
await primary(page).click();
expect((await saved).request().postDataJSON().target.root_index).toBe(1);
await expect(toast(page)).toContainText('tags saved for dunes_00001.png.');
expect(await savedTags(request, FIRST, 1)).toContain('kept_draft');
});
test('the inspector finds a renumbered file again; checked files from the old numbering are cleared', async ({ page, request }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await card(page, FIRST).getByRole('checkbox', { name: /^Select / }).check();
await expect(page.locator('.bulkbar')).toBeVisible();
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
const refused = page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${SECOND}` &&
new URL(item.url()).searchParams.get('root') === '0');
const reread = page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${SECOND}` &&
new URL(item.url()).searchParams.get('root') === '1');
await infoButton(page, SECOND).click();
expect((await refused).status()).toBe(409);
expect((await reread).status()).toBe(200);
await expect(card(page, SECOND, 1)).toHaveClass(/\bselected\b/);
await expect(panel(page)).not.toContainText('The source changed');
await expect(panel(page).getByRole('button', { name: 'Refresh image collection', exact: true })).toHaveCount(0);
// A checked key of the old list could name a same-named file in another source: nothing stays checked.
await expect(page.locator('.bulkbar')).toBeHidden();
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
});
test('a scope that cannot be mapped: Generate and Save are visibly disabled, the reason and Refresh sit beside them', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toBeVisible();
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
const rescoped = answered(page, '/images/rescope');
expect((await openTag(page)).status()).toBe(409);
expect((await rescoped).status()).toBe(409);
await expect(notice(page)).toContainText(SOURCE_CHANGED);
await expect(status(page)).toHaveText('');
const refresh = notice(page).getByRole('button', { name: 'Refresh image collection', exact: true });
await expect(refresh).toBeVisible();
for (const button of [generate(page), primary(page)]) {
await expect(button).toHaveAttribute('aria-disabled', 'true');
await expect(button).toHaveCSS('opacity', '0.55');
}
// The notice is its own first row of the sticky actions, above the buttons.
const rows = await dialog(page).locator('.dialog-actions').evaluate(actions => {
const noticeBox = actions.firstElementChild.getBoundingClientRect();
return { first: actions.firstElementChild.className, below: [...actions.querySelectorAll(':scope > button')].every(button => button.getBoundingClientRect().top >= noticeBox.bottom - 1) };
});
expect(rows).toEqual({ first: 'tag-action-notice', below: true });
const preview = dialog(page).locator('.tag-image-preview');
await expect(preview).toHaveText('Preview unavailable');
await expect(preview).toHaveAttribute('role', 'img');
const suggest = [];
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) suggest.push(item.url()); });
// Pressed anyway (Playwright refuses an aria-disabled target, so the press is dispatched), nothing is sent.
await generate(page).dispatchEvent('click');
await primary(page).dispatchEvent('click');
expect(suggest).toEqual([]);
await page.screenshot({ path: test.info().outputPath('stale-source-tag-dialog.png') });
// Refresh closes the dialog and lists again with the current scopes; Tag then works.
const relisted = page.waitForResponse(item => item.url().includes('/images/output?'));
await refresh.click();
await expect(dialog(page)).toHaveCount(0);
await relisted;
await expect(card(page, FIRST)).toBeVisible();
expect((await openTag(page)).status()).toBe(200);
await expect(notice(page)).toHaveCount(0);
await expect(generate(page)).not.toHaveAttribute('aria-disabled', 'true');
});
test('an unmappable inspector read keeps its visible Refresh, and Refresh recovers the panel', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
const rescoped = answered(page, '/images/rescope');
await infoButton(page, SECOND).click();
expect((await rescoped).status()).toBe(409);
await expect(panel(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
const relisted = page.waitForResponse(item => item.url().includes('/images/output?'));
await panel(page).getByRole('button', { name: 'Refresh image collection', exact: true }).click();
await relisted;
await expect(cards(page).first()).toHaveClass(/\bselected\b/);
await expect(panel(page)).not.toContainText('The source changed');
});
test('a Load more cursor the server cannot verify offers Refresh instead of a dead Retry', async ({ page, request }) => {
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: 60 } })).toBeOK();
await page.goto(images);
await expect(cards(page).first()).toBeVisible();
await expect(page.locator('#gallery-continuation')).toContainText('more load as you scroll');
await expect(await request.post('/__test__/scope-key/rotate')).toBeOK();
const stale = page.waitForResponse(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.has('cursor'));
await page.locator('#gallery-continuation').scrollIntoViewIfNeeded();
const answer = await stale;
expect(answer.status()).toBe(400);
expect((await answer.json()).code).toBe('cursor-stale');
const continuation = page.locator('#gallery-continuation');
await expect(continuation).toContainText('The image collection changed. Refresh image collection to continue.');
const relisted = page.waitForResponse(item => item.url().includes('/images/output?') && !new URL(item.url()).searchParams.has('cursor'));
await continuation.getByRole('button', { name: 'Refresh image collection', exact: true }).click();
expect((await relisted).status()).toBe(200);
await expect(continuation).not.toContainText('The image collection changed');
});
test('a viewer whose media source was renumbered closes onto the file, kept selected', async ({ page, request }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
const rescoped = answered(page, '/images/rescope');
await card(page, FIRST).locator('.image-open').click();
const mapped = await (await rescoped).json();
expect([mapped.image.root_index, mapped.image.path]).toEqual([1, FIRST]);
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
// Opened again, it shows the file under its current source.
await card(page, FIRST, 1).locator('.image-open').click();
await expect(page.locator('#viewer-dialog[open]')).toBeVisible();
await expect(page.locator('#viewer-dialog').getByText('Media could not be loaded. Retry this file.', { exact: true })).toBeHidden();
await expect.poll(() => page.locator('#viewer-dialog img').first().evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
});
test('a collection page finds a renumbered member again and stays on its members', async ({ page, request }) => {
// Members are stored by root path, not index (#331), so a renumbering keeps them; the page's scopes go stale.
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
const first = listing.images.find(image => image.path === FIRST && image.root_index === 0);
const name = `Stale ${test.info().workerIndex}-${Date.now()}`;
const added = await request.post('/prompt_manager/collections/items', { data: { name, items: [
{ root_index: 0, root_scope: first.root_scope, path: FIRST }] } });
await expect(added).toBeOK();
const collections = (await (await request.get('/prompt_manager/collections')).json()).collections;
const { id } = collections.find(item => item.name === name);
await page.goto(`/prompt_manager/workspace#/collection/${id}`);
await expect(cards(page)).toHaveCount(1);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
const rescoped = answered(page, '/images/rescope');
const relisted = page.waitForResponse(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('collection') === String(id));
await card(page, FIRST).locator('.image-open').click();
expect((await (await rescoped).json()).image.root_index).toBe(1);
expect((await relisted).status()).toBe(200);
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
await expect(cards(page)).toHaveCount(1);
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
// Tag image works on the member under its new source.
expect((await openTag(page, FIRST, 1)).status()).toBe(200);
await expect(notice(page)).toHaveCount(0);
});
@@ -1,7 +1,6 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept } from './concept-inventory.mjs';
import { followRestore, SEEN_RESTORE_PILL, SEEN_RESTORE_TOAST } from './unseen-restore.mjs';
// System › Backup & export (#249 System S9 Task 24) on the S8 backend and the concept-shaped fixture: Back up and its
// status line (D103), the restore review (D105) and its job, the exports, Import / inspect and the recovery policy
@@ -266,24 +265,6 @@ test('a finished restore shows the open page again from the restored data', asyn
expect(await libraryTotal(request)).toBe(restoredTotal);
});
// #415: an open dialog waits out a finished restore, except a review: the plan it staged no longer describes storage,
// so it closes as dismissed (its stage discarded), as before.
test('a restore seen finishing elsewhere closes an open restore review and discards its stage', async ({ page, request }) => {
const bytes = await backupBytes(request);
const restoreRun = await followRestore(page);
await openBackups(page);
await expect(page.locator('#activity-pill')).toHaveAttribute('href', SEEN_RESTORE_PILL);
await chooseBackup(page, bytes);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(1);
const discarded = page.waitForResponse(response => response.url().includes(`${PREVIEW_URL}/`) && response.request().method() === 'DELETE');
await restoreRun.finish();
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
expect((await discarded).status()).toBe(200);
await expect(dialog(page)).toHaveCount(0);
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
});
test('invalid file shows the validator message; nothing is staged', async ({ page, request }) => {
const before = await serverJson(request, '/__test__/system/digest');
await openBackups(page);
+17 -114
View File
@@ -79,7 +79,7 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
expect(await rowsOf(layout, 'Workspace preferences')).toEqual([
['One display configuration', 'Grid and density settings are shared across views.', 'Appearance'],
['2 output sources', 'Monitoring and enrichment share one source list.', 'Manage sources'],
['Statistics', 'Runs by category, with library totals.', 'Explore'],
['Statistics', 'Runs and ratings by category, with library totals.', 'Explore'],
]);
expect(writes).toEqual([]);
});
@@ -287,8 +287,7 @@ test('Appearance › Thumbnails saves the server thumbnail settings and never co
await expect(await request.post('/__test__/system/jxl-encoder', { data: { available: false } })).toBeOK();
await expect(await request.post('/__test__/system/legacy-previews', { data: { count: 1 } })).toBeOK();
await page.goto(`${workspace}#/system/appearance`);
// #450 D156: the Viewer surface sits between Browsing & display and Thumbnails.
const surface = page.locator('#workspace-main .settings-layout > section.surface').nth(2);
const surface = page.locator('#workspace-main .settings-layout > section.surface').nth(1);
await expect(surface.locator(':scope > h2')).toHaveText('Thumbnails');
await expect(surface.locator('.setting-row label')).toHaveText(['Thumbnail format', 'Thumbnail quality', 'Thumbnail size limit']);
const format = surface.locator('#thumbnail-format');
@@ -374,12 +373,13 @@ test('statistics dialog lists runs by category and library totals', async ({ pag
await expect(dialog.locator('#modal-title')).toHaveText('Usage statistics');
const tables = dialog.locator('#modal-body > table');
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']);
// D153 (#447): no rating column and no rated-images total.
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs']);
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs', 'Avg. rating']);
const cells = table => table.locator('tbody tr').evaluateAll(rows => rows.map(row => [...row.cells].map(cell => cell.textContent)));
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]));
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized',
number(row.runs), row.average_rating === null ? 'Not rated' : `${number(row.average_rating)} / 5`]));
expect(await cells(tables.nth(1))).toEqual([['Prompts', number(statistics.totals.total_prompts)],
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)]]);
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)],
['Rated images', number(statistics.rated_images)]]);
await page.keyboard.press('Escape');
await expect(explore).toBeFocused();
// A statistics read that fails says so; the Overview stays.
@@ -472,7 +472,7 @@ async function openImages(page) {
test('defaults apply to a new visit but not to the current view or a saved view', async ({ page, request }) => {
const created = await request.post('/prompt_manager/saved-views', { data: { page: 'images', name: 'Everything',
state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' } } });
state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false } } });
expect(created.status()).toBe(201);
const view = (await created.json()).view;
try {
@@ -481,19 +481,17 @@ test('defaults apply to a new visit but not to the current view or a saved view'
await page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'List', exact: true }).click();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
await openAppearance(page);
// #451: a fresh browser's Default layout is Masonry.
await expect(page.locator('#default-view')).toHaveValue('masonry');
await page.locator('#default-view').selectOption('grid');
await expect(page.locator('#default-view')).toHaveValue('grid');
await page.locator('#default-view').selectOption('masonry');
await page.locator('#density').selectOption('compact');
await expect(statusOf(page)).toHaveText('Unsaved changes.');
await saveAppearance(page, 'Saved in this browser.');
const stored = await storedSettings(page);
expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['grid', 'compact']);
// Back in Images (the page session's last visit): the toolbar's List is the current view's own and stays; the
// size the toolbar never changed follows the newer default (#451).
expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['masonry', 'compact']);
// Back in Images (the page session's last visit): the current view keeps its own layout and size.
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable');
// The saved view opens as before.
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(imageCards(page).first()).toBeVisible();
@@ -503,109 +501,15 @@ test('defaults apply to a new visit but not to the current view or a saved view'
await openImages(page);
await page.reload();
await expect(imageCards(page).first()).toBeVisible();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'grid');
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'masonry');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Grid', exact: true }))
await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true }))
.toHaveAttribute('aria-pressed', 'true');
} finally {
await request.delete(`/prompt_manager/saved-views/${view.id}`);
}
});
/** Choose a Default layout in System › Appearance and save it. */
async function saveDefaultLayout(page, value) {
await openAppearance(page);
await page.locator('#default-view').selectOption(value);
await saveAppearance(page, 'Saved in this browser.');
}
const imageLayoutOf = page => page.locator('#workspace-main .image-grid');
const layoutButton = (page, name) => page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name, exact: true });
test('a fresh browser opens Images in Masonry (#451)', async ({ page }) => {
await openImages(page);
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
await expect(layoutButton(page, 'Masonry')).toHaveAttribute('aria-pressed', 'true');
// Nothing was stored for it: the default is the gallery settings' own.
expect(await page.evaluate(() => localStorage.getItem('gallerySettings'))).toBeNull();
// A stored explicit choice is kept.
await page.evaluate(() => localStorage.setItem('gallerySettings', JSON.stringify({ workspaceLayout: 'grid' })));
await page.reload();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
});
test('a Default layout saved later reaches the next Images visit in this tab (#451)', async ({ page }) => {
// The visit starts from Grid, then System saves Masonry without a reload.
await page.addInitScript(() => { if (!localStorage.getItem('gallerySettings')) localStorage.setItem('gallerySettings', '{"workspaceLayout":"grid"}'); });
await openImages(page);
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
await page.locator('#system-nav a[data-route="system"]').click();
await expect(page.locator('#workspace-main .settings-layout')).toBeVisible();
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
await expect(page.locator('#timeout')).toBeEnabled();
await page.locator('#default-view').selectOption('masonry');
await saveAppearance(page, 'Saved in this browser.');
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
await expect(layoutButton(page, 'Masonry')).toHaveAttribute('aria-pressed', 'true');
});
test('a Default layout saved in another tab reaches this tab\'s next Images visit (#451)', async ({ page, context }) => {
await page.addInitScript(() => { if (!localStorage.getItem('gallerySettings')) localStorage.setItem('gallerySettings', '{"workspaceLayout":"grid"}'); });
await openImages(page);
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
const other = await context.newPage();
try {
await saveDefaultLayout(other, 'list');
} finally {
await other.close();
}
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(page.locator('#workspace-main .prompt-card').first()).toBeVisible();
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'list');
// A toolbar choice afterwards is this view's own again: a return keeps it.
await layoutButton(page, 'Masonry').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
await page.locator('#primary-nav a[data-route="library"]').click();
await expect(page.locator('#workspace-main .prompt-card').first()).toBeVisible();
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(imageCards(page).first()).toBeVisible();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
});
// #451 D157: a cover crop keeps a portrait image's top; Masonry shows the whole image and keeps the centre.
test('cover crops keep the top of a portrait image; Masonry stays centred (#451)', async ({ page }) => {
const position = locator => locator.evaluate(node => getComputedStyle(node).objectPosition);
const portrait = page.locator('#workspace-main article.media-card .image-open img[width="1024"][height="1536"]').first();
await openImages(page);
await layoutButton(page, 'Grid').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
await expect(portrait).toBeVisible();
expect(await position(portrait)).toBe('50% 20%');
await layoutButton(page, 'List').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'list');
expect(await position(portrait)).toBe('50% 20%');
// The side panel's cover (also History's media inspector).
await portrait.locator('xpath=ancestor::article[1]').locator('.image-card-actions button').first().click();
await expect(panel(page).locator('.detail-cover')).toBeVisible();
expect(await position(panel(page).locator('.detail-cover'))).toBe('50% 20%');
await layoutButton(page, 'Masonry').click();
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
expect(await position(portrait)).toBe('50% 50%');
// The Library's card art and a prompt history's shared gallery.
await page.goto(`${workspace}#/library`);
const art = page.locator('#workspace-main .prompt-card .card-art img').first();
await expect(art).toBeVisible();
expect(await position(art)).toBe('50% 20%');
await page.goto(`${workspace}#/library/${page.manifest.prompts[0].positive_id}`);
await expect(imageCards(page).first()).toBeVisible();
expect(await position(imageCards(page).first().locator('.image-open img'))).toBe('50% 20%');
});
test('the defaults reach the Images page only; embedded galleries keep their own presentation', async ({ page }) => {
await openAppearance(page);
await page.locator('#default-view').selectOption('list');
@@ -883,13 +787,12 @@ test('reset restores defaults without touching other keys', async ({ page }) =>
await expect(page.locator('#default-view')).toHaveValue('list');
const reset = page.locator('#workspace-main .operation').filter({ hasText: 'Reset display defaults' }).getByRole('button', { name: 'Reset', exact: true });
await reset.click();
// #451 D157: the default layout is Masonry.
await expect(page.locator('#default-view')).toHaveValue('masonry');
await expect(page.locator('#default-view')).toHaveValue('grid');
await expect(page.locator('#video-muted')).toBeChecked();
await expect(statusOf(page)).toHaveText('Unsaved changes.');
await saveAppearance(page, 'Saved in this browser.');
const stored = await storedSettings(page);
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'masonry',
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'grid',
videoMute: true, showImageInfo: true });
});
@@ -16,7 +16,7 @@ const toasts = page => page.evaluate(() => window.__toasts);
const chips = page => modal(page).locator('#tag-suggestions .chip.tag-suggestion');
const actionButtons = page => modal(page).locator('.dialog-actions button');
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.';
test.beforeEach(async ({ page, request, browserName }) => {
@@ -73,7 +73,7 @@ const jobStart = page => page.waitForRequest(item => item.method() === 'POST' &&
test('selection bar shows Add tags then Auto-tag selected before Set category', async ({ page }) => {
await select(page, FIRST);
await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']);
await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']);
});
test('Add tags runs a job and the cards show the tags after it finishes', async ({ page, request }) => {
@@ -110,7 +110,7 @@ test('Add tags runs a job and the cards show the tags after it finishes', async
await expect(bar(page).locator('strong')).toHaveText('2 selected');
expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 });
// The finished job's files are read again: the selected card's panel shows its new image tags.
await expect(imageTags(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
expect(await savedTags(request, SECOND)).toEqual(['sunset', 'blue_hour']);
expect(await savedTags(request, UNLINKED)).toEqual([]);
});
@@ -275,7 +275,7 @@ test('Review each image walks the selection one dialog at a time and only approv
expect(await savedTags(request, SECOND)).toEqual([]);
expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']);
// The reviewed file's panel shows its saved tags without reselecting.
await expect(imageTags(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
await expect.poll(async () => (await (await request.get('/__test__/tagging/captures')).json()).captures).toBe(0);
});
+21 -66
View File
@@ -21,8 +21,6 @@ const model = page => dialog(page).getByRole('combobox', { name: 'Tagging model'
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
const status = page => dialog(page).locator('#tag-generation-status');
// D147: the reason a state blocks Save, on its own row in the sticky actions.
const notice = page => dialog(page).locator('.dialog-actions > .tag-action-notice');
const toast = page => page.locator('#workspace-toast');
const nav = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: new RegExp(`^${label}(?:\\s*[\\d,]+)?$`) });
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', COPY = 'copies/dunes_00001.png';
@@ -458,32 +456,27 @@ test('virtualized cards release listeners on unmount', async ({ page, request })
expect(await count()).toBe(mounted);
});
// Task C3 (D88), #449 (D154): the side panel and the viewer details draw the file's own current image tags in their own
// "Image tags" section, then D21's linked tags in a "Prompt tags" or "Recipe tags" section after it. Panel image tags are
// Tags filter buttons; the viewer's chips are plain.
// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked
// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain.
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const linkedTags = page => panel(page).locator('.tag-section.linked-tags > .tags');
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`);
const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
test('inspector shows saved image tags after Save without reselecting', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
// D21/D39: the linked recipe's own tag, plain, and no image tags yet.
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(linkedTags(page).locator('button.chip')).toHaveCount(0);
await expect(panel(page).locator('.tag-section.image-tags')).toHaveCount(0);
// D21/D39: the linked recipe's own tag, plain.
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('button.chip')).toHaveCount(0);
await openTag(page, FIRST, { goto: false });
await manual(page).fill('dunes_tag, Editorial');
const reread = inspected(page, FIRST);
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] });
// The image tags lead as filter buttons in their own section; the recipe keeps its "editorial" in its own (D154).
await expect(imageTags(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(panel(page).locator('.tag-section.image-tags .role-label')).toHaveText('Image tags · 2');
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
// The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial".
await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
});
@@ -495,10 +488,9 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
await infoButton(page, UNLINKED).click();
await read;
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
await expect(imageTags(page).locator('.chip')).toHaveText(['loose_tag']);
await expect(panel(page).locator('.tag-section.linked-tags')).toHaveCount(0);
await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']);
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag');
await imageTags(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
await listing;
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
await expect(card(page, UNLINKED)).toBeVisible();
@@ -508,53 +500,18 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
test('viewer details show the same chips', async ({ page, request }) => {
await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']);
await page.goto(images);
await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await card(page, FIRST).locator('.image-open').click();
const details = page.locator('#viewer-dialog[open] #viewer-details');
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
const row = details.getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const row = details.locator('.tags').last();
await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await expect(row.locator('span.chip')).toHaveCount(3);
await expect(row.locator('button')).toHaveCount(0);
await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
// A file without image tags: the viewer shows only D21's linked tags.
await page.getByRole('button', { name: 'Next media', exact: true }).click();
await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`);
await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
await expect(details.locator('.tag-section.image-tags')).toHaveCount(0);
});
// #449 (D154): the image tags are a labelled region of their own after the negative prompt, framed like a prompt section,
// so they never read as part of it; the same in the side panel and the viewer details.
async function tagSectionPlacement(root) {
return root.evaluate(node => {
const negative = node.querySelector('section.prompt-section.negative'), tags = node.querySelector('section.tag-section.image-tags');
const style = getComputedStyle(tags), label = getComputedStyle(tags.querySelector('.prompt-heading .role-label'));
return {
after: Boolean(negative.compareDocumentPosition(tags) & Node.DOCUMENT_POSITION_FOLLOWING), inside: negative.contains(tags),
below: tags.getBoundingClientRect().top >= negative.getBoundingClientRect().bottom,
border: `${style.borderTopWidth} ${style.borderTopStyle}`, paddingTop: style.paddingTop, marginTop: style.marginTop,
label: {transform: label.textTransform, size: label.fontSize, sameColorAsNegative: label.color === getComputedStyle(negative.querySelector('.role-label')).color},
};
});
}
const PLACED = {after: true, inside: false, below: true, border: '1px solid', paddingTop: '15px', marginTop: '15px',
label: {transform: 'uppercase', size: '10px', sameColorAsNegative: false}};
test('D154: image tags sit in their own labelled section after the negative prompt, in the panel and the viewer', async ({ page, request }) => {
await saveImageTags(request, FIRST, ['dunes_tag', 'sunlit']);
await page.goto(images);
const section = panel(page).getByRole('region', { name: 'Image tags', exact: true });
await expect(section.locator('.role-label')).toHaveText('Image tags · 2');
await expect(section.locator('.tags > button.chip')).toHaveText(['dunes_tag', 'sunlit']);
expect(await tagSectionPlacement(panel(page))).toEqual(PLACED);
await card(page, FIRST).locator('.image-open').click();
const details = page.locator('#viewer-dialog[open] #viewer-details');
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
const viewerSection = details.getByRole('region', { name: 'Image tags', exact: true });
await expect(viewerSection.locator('.role-label')).toHaveText('Image tags · 2');
await expect(viewerSection.locator('.tags > span.chip')).toHaveText(['dunes_tag', 'sunlit']);
expect(await tagSectionPlacement(details)).toEqual(PLACED);
await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags);
});
test('search finds an image by its image tag', async ({ page, request }) => {
@@ -611,7 +568,7 @@ const listingOf = (page, check) => page.waitForResponse(item => item.url().inclu
test('the post-save re-read keeps the panel content until the new read lands', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await page.evaluate(() => {
const node = document.querySelector('aside[aria-label="Selected image details"]');
window.__loadingSeen = false;
@@ -630,13 +587,12 @@ test('the post-save re-read keeps the panel content until the new read lands', a
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
// While the re-read is held, the panel still shows the file's details.
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
expect(await page.evaluate(() => window.__loadingSeen)).toBe(false);
const landed = inspected(page, FIRST);
release();
await landed;
await expect(imageTags(page).locator('.chip')).toHaveText(['held_tag']);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('.chip')).toHaveText(['held_tag', ...manifest.pair_tags]);
expect(await page.evaluate(() => { window.__loadingWatch.disconnect(); return window.__loadingSeen; })).toBe(false);
await page.unroute(matcher);
});
@@ -649,7 +605,7 @@ test('a save under a Tags filter re-reads the listing: the selection stays while
await infoButton(page, SECOND).click();
await read;
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
// The newest match (SECOND) is selected once the filter applies.
@@ -772,8 +728,7 @@ test('D116: Retry leads the actions after an unconfirmed save and resends the sa
let lost = true;
await page.route('**/prompt_manager/image-annotations/tags', route => (lost ? (lost = false, route.fulfill({ status: 504, body: 'Gateway Timeout' })) : route.continue()));
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
await expect(notice(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
await expect(status(page)).toHaveText('');
await expect(status(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
const inventory = await page.locator('dialog#modal').evaluate(taggingInventory);
expect(inventory.actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
@@ -809,7 +764,7 @@ test('a tag save from Tags reloads a filtered Images visit instead of restoring
await page.goto(images);
await infoButton(page, SECOND).click();
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
@@ -834,7 +789,7 @@ test('a failed listing re-read after a save says so and keeps the cards (M2)', a
await page.goto(images);
await infoButton(page, SECOND).click();
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
await openTag(page, SECOND, { goto: false });
@@ -1,142 +0,0 @@
// #450 (TESTING.md D156): every viewer has a full-screen toggle beside ✕. Full screen fills the browser window edge to
// edge, Centered is the concept's framed dialog; the button names the view it switches to. System › Appearance ›
// Viewer › "Open viewer in" chooses how the viewer opens; the button changes the current view only.
import { test, expect } from './fixtures.mjs';
import { seedConcept, openProduction } from './concept-inventory.mjs';
import { parityPng } from './local-inspect-files.mjs';
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const CARDS = '.gallery-body > .image-grid > .media-card';
const viewer = page => page.locator('dialog#viewer-dialog[open]');
const modeButton = page => viewer(page).locator('.dialog-header > button#viewer-mode');
const details = page => viewer(page).locator('#viewer-details');
test.beforeEach(async ({ request }, testInfo) => { testInfo.manifest = await seedConcept(request); });
async function openImages(page, { width = 1600, height = 1100, theme = 'dark' } = {}) {
await page.setViewportSize({ width, height });
await openProduction(page, 'images', theme);
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page).locator('#viewer-details .panel-heading')).toBeVisible();
}
/** The dialog's box and edges against the window. */
const frame = page => viewer(page).evaluate(node => {
const box = node.getBoundingClientRect(), style = getComputedStyle(node);
return { left: Math.round(box.left), top: Math.round(box.top), right: Math.round(innerWidth - box.right),
bottom: Math.round(document.documentElement.clientHeight - box.bottom), radius: style.borderTopLeftRadius,
border: style.borderTopWidth, fills: Math.abs(box.width - document.documentElement.clientWidth) <= 1 };
});
test('the button beside ✕ switches between Centered and Full screen and names the view it switches to', async ({ page }) => {
await openImages(page);
const header = await viewer(page).locator('.dialog-header').evaluate(node => [...node.children].map(child => child.id || child.localName));
expect(header).toEqual(['div', 'viewer-mode', 'viewer-close']);
await expect(modeButton(page)).toHaveAccessibleName('Full screen view');
await expect(modeButton(page)).toHaveAttribute('title', 'Full screen view');
await expect(modeButton(page).locator('svg.icon')).toHaveCount(1);
const centered = await frame(page);
expect(centered.left).toBeGreaterThan(0);
expect(centered.fills).toBe(false);
// Keyboard: the button is reachable and keeps focus after a switch.
await viewer(page).locator('#viewer-close').focus();
await page.keyboard.press('Shift+Tab');
await expect(modeButton(page)).toBeFocused();
await page.keyboard.press('Enter');
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await expect(modeButton(page)).toBeFocused();
await expect(modeButton(page)).toHaveAccessibleName('Centered view');
expect(await frame(page)).toEqual({ left: 0, top: 0, right: 0, bottom: 0, radius: '0px', border: '0px', fills: true });
// The details stay toggleable in full screen.
const toggle = viewer(page).getByRole('button', { name: 'Hide details', exact: true });
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await toggle.click();
await expect(details(page)).toBeHidden();
await viewer(page).getByRole('button', { name: 'Show details', exact: true }).click();
await expect(details(page)).toBeVisible();
// Back to Centered: today's dialog, with no toggle while the collapse setting is off.
await modeButton(page).click();
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
expect(await frame(page)).toEqual(centered);
await expect(viewer(page).getByRole('button', { name: /^(Show|Hide) details$/ })).toHaveCount(0);
// Navigation keeps the current view; Esc closes from full screen; the next open is Centered again.
await modeButton(page).click();
await viewer(page).getByRole('button', { name: 'Next media', exact: true }).click();
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await viewer(page).getByRole('button', { name: 'Hide details', exact: true }).focus();
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
});
test('switching back to Centered keeps the collapse setting\'s toggle and hidden details (D97)', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ sidebarCollapsedByDefault: true })));
await page.setViewportSize({ width: 1600, height: 1100 });
await openProduction(page, 'images', 'dark');
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page)).toBeVisible();
await expect(details(page)).toBeHidden();
await modeButton(page).click();
await expect(viewer(page).getByRole('button', { name: 'Show details', exact: true })).toBeVisible();
await modeButton(page).click();
await expect(details(page)).toBeHidden();
await expect(viewer(page).getByRole('button', { name: 'Show details', exact: true })).toHaveAttribute('aria-expanded', 'false');
});
test('System › Appearance › Viewer chooses how the viewer opens; the button changes only the current view', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await openProduction(page, 'system/appearance', 'dark');
const surface = page.locator('#workspace-main section.surface').filter({ has: page.locator('h2', { hasText: /^Viewer$/ }) });
await expect(surface.locator('.setting-row > div > :is(label, h3)')).toHaveText(['Open viewer in', 'Collapse viewer details by default']);
const select = surface.getByRole('combobox', { name: 'Open viewer in', exact: true });
await expect(select).toHaveValue('centered');
await select.selectOption('fullscreen');
await page.getByRole('button', { name: 'Save preferences', exact: true }).click();
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings') || '{}').viewerOpenMode)).toBe('fullscreen');
await page.evaluate(() => { location.hash = '#/images'; });
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await expect(modeButton(page)).toHaveAccessibleName('Centered view');
await modeButton(page).click();
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings')).viewerOpenMode)).toBe('fullscreen');
});
test('Inspect opens full screen with its details when that is the default (D155)', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ viewerOpenMode: 'fullscreen', sidebarCollapsedByDefault: true })));
await page.setViewportSize({ width: 1600, height: 1100 });
await openProduction(page, 'inspect', 'dark');
await page.locator('#local-file').setInputFiles(parityPng('f02.png'));
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await expect(details(page)).toBeVisible();
await expect(viewer(page).getByRole('button', { name: 'Hide details', exact: true })).toBeVisible();
});
test('the Library gallery viewer has the button too', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await openProduction(page, `library/${testInfo.manifest.prompts[0].positive_id}`, 'dark');
await page.locator('.media-card .image-open').first().click();
await expect(modeButton(page)).toHaveAccessibleName('Full screen view');
});
test('at 390px the viewer is already full-bleed and the button hides', async ({ page }) => {
await openImages(page, { width: 390, height: 844 });
await expect(viewer(page).locator('#viewer-mode')).toBeHidden();
await expect(viewer(page).locator('#viewer-close')).toBeVisible();
});
test('full screen holds at 200% zoom and coarse pointers get a 40px target', async ({ page, browserName }) => {
await openImages(page);
await modeButton(page).click();
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
const zoomed = await frame(page);
expect(zoomed).toMatchObject({ left: 0, top: 0, right: 0, bottom: 0 });
await page.evaluate(() => { document.documentElement.style.zoom = '1'; });
if (await page.evaluate(() => matchMedia('(pointer: coarse)').matches)) {
const size = await modeButton(page).evaluate(node => [node.getBoundingClientRect().width, node.getBoundingClientRect().height]);
expect(Math.min(...size)).toBeGreaterThanOrEqual(40);
}
test.info().annotations.push({ type: 'engine', description: browserName });
});
-3
View File
@@ -4,7 +4,6 @@ import { createHash } from 'node:crypto';
import { captureCanonicalState, historyGeometry, compareCanonicalHistory, nativeDialogState, exerciseCurrentPairSend, assertRecordedHistory } from './history-canonical-comparison.mjs';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { startInGrid } from './concept-inventory.mjs';
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const workspace = '/prompt_manager/workspace';
@@ -416,8 +415,6 @@ test('zoomed virtual Images keeps the visible real media mounted during native s
test('supplemental extreme zoom grows real footers preserves the visible anchor and resets reservations', async ({ page, request }) => {
await request.post('/__test__/reset', { data: { workspace_visual: true } });
// The Grid's footer reservations (D157: the default layout is Masonry).
await startInGrid(page);
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
await expect(cards).toHaveCount(24);
@@ -1,167 +0,0 @@
// #444 (D149): Tags & categories tag search, tag sort and the Untagged prompts row, against the real server.
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores} from './unseen-restore.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
const route='/prompt_manager/workspace#/tags';
const main=page=>page.locator('#workspace-main');
const tagSurface=page=>main(page).locator('.two-col > section.surface').first();
const table=page=>main(page).getByRole('table',{name:'Reusable tags',exact:true});
const rows=page=>table(page).locator('tbody tr');
const names=page=>rows(page).locator('td:first-child');
const tagRow=(page,name)=>rows(page).filter({has:page.locator('td:first-child').getByText(name,{exact:true})});
const search=page=>page.getByRole('searchbox',{name:'Search tags',exact:true});
const sort=page=>page.getByRole('combobox',{name:'Sort tags',exact:true});
const untagged=page=>tagSurface(page).locator('.operation').filter({has:page.getByRole('heading',{level:3,name:'Untagged prompts',exact:true})});
const status=page=>tagSurface(page).locator('search.vocabulary-tools [role="status"]');
const shelf=page=>page.locator('article[data-prompt-id]');
const filtersButton=(page,name)=>page.locator('#workspace-main search.toolbar').getByRole('button',{name,exact:true});
const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true});
async function seed(request,data={}) {
const response=await request.post('/__test__/vocabulary/reset',{data});await expect(response).toBeOK();return response.json();
}
let manifest;
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await armUnseenRestores(page);
// Two minimal recipes without tags (the concept recipes all carry tags).
manifest=await seed(request,{extra_categories:2});
page.vocabularyErrors=[];page.on('pageerror',error=>page.vocabularyErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.vocabularyErrors).toEqual([]);});
async function open(page) {
await page.goto(route);await expect(rows(page)).toHaveCount(manifest.tags.length);
}
test('the tag vocabulary surface orders its paragraph, Untagged prompts, the search and sort, then the table',async({page})=>{
await open(page);
const order=await tagSurface(page).evaluate(node=>[...node.children].map(child=>child.matches('.operation')?'operation':
child.matches('search.toolbar')?'toolbar':child.matches('.table-wrap')?'table':child.tagName.toLowerCase()).slice(0,5));
expect(order).toEqual(['h2','p','operation','toolbar','table']);
await expect(untagged(page).locator('p')).toHaveText('2 positive prompts');
await expect(untagged(page).getByRole('button')).toHaveText(['Browse']);
await expect(search(page)).toHaveAttribute('placeholder','Search tags…');
await expect(search(page)).toHaveAttribute('maxlength','200');
await expect(sort(page).locator('option')).toHaveText(['Oldest first','Name A–Z','Name Z–A','Most prompts','Fewest prompts']);
await expect(sort(page)).toHaveValue('created');
await expect(tagSurface(page).locator('label.search-field svg, label.search-field .ca-icon').first()).toBeVisible();
});
test('search narrows the tags on the server, says when nothing matches and keeps the categories',async({page})=>{
await open(page);
const categories=await main(page).locator('.two-col > section.surface').nth(1).locator('.operation h3').allTextContents();
const reads=[];page.on('request',request=>{if(request.url().includes('/vocabulary/tags?'))reads.push(new URL(request.url()).searchParams);});
await search(page).fill('LIGHT');
await expect(names(page)).toHaveText(['warm_light','soft_light']);
await expect(status(page)).toHaveText('2 tags');
expect(reads.at(-1).get('search')).toBe('LIGHT');
// Literal: an underscore is not a wildcard.
await search(page).fill('a_c');await expect(rows(page)).toHaveCount(0);
await expect(tagSurface(page).locator('p.empty')).toHaveText('No tags match this search.');
await expect(status(page)).toHaveText('No tags match this search.');
await expect(main(page).locator('.two-col > section.surface').nth(1).locator('.operation h3')).toHaveText(categories);
await search(page).fill('');await expect(rows(page)).toHaveCount(manifest.tags.length);
await expect(status(page)).toHaveText(`${manifest.tags.length} tags`);
});
test('sort orders every tag by name or by prompts, ties in creation order',async({page})=>{
await open(page);
const created=await names(page).allTextContents();
await sort(page).selectOption('name');
const byName=[...created].sort((a,b)=>a.toLowerCase()<b.toLowerCase()?-1:a.toLowerCase()>b.toLowerCase()?1:0);
await expect(names(page)).toHaveText(byName);
await sort(page).selectOption('name-desc');await expect(names(page)).toHaveText([...byName].reverse());
await sort(page).selectOption('prompts');
await expect(rows(page).first().locator('td:first-child')).toHaveText('cinematic');
const counts=(await rows(page).locator('td:nth-child(2)').allTextContents()).map(Number);
expect(counts).toEqual([...counts].sort((a,b)=>b-a));
await sort(page).selectOption('prompts-asc');
const ascending=(await rows(page).locator('td:nth-child(2)').allTextContents()).map(Number);
expect(ascending).toEqual([...ascending].sort((a,b)=>a-b));
await sort(page).selectOption('created');await expect(names(page)).toHaveText(created);
});
test('a rename while searching re-reads the same search and sort',async({page})=>{
await open(page);
await sort(page).selectOption('name');await search(page).fill('light');
await expect(names(page)).toHaveText(['soft_light','warm_light']);
await tagRow(page,'warm_light').getByRole('button',{name:'Rename / merge',exact:true}).click();
const box=page.locator('dialog.vocabulary-dialog');
await box.getByLabel('New name; existing names merge',{exact:true}).fill('amber_light');
await box.getByRole('button',{name:'Apply',exact:true}).click();await expect(box).toHaveCount(0);
await expect(names(page)).toHaveText(['amber_light','soft_light']);
await expect(tagRow(page,'amber_light').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
await expect(search(page)).toHaveValue('light');await expect(sort(page)).toHaveValue('name');
});
test('Untagged prompts Browse opens the Library on exactly the prompts without tags',async({page})=>{
await open(page);
await untagged(page).getByRole('button',{name:'Browse',exact:true}).click();
await expect(page).toHaveURL(/#\/library$/);
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
await expect(shelf(page)).toHaveCount(2);
await expect(page.locator('#workspace-main .section-caption > span').first()).toHaveText(/^2 positive prompts · /);
for(const card of await shelf(page).all())await expect(card.locator('.tags .chip')).toHaveCount(0);
await filtersButton(page,'Filters · 1').click();
await expect(refine(page).getByRole('combobox',{name:'Tag match',exact:true})).toHaveValue('untagged');
await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toBeDisabled();
// Changing only the Tag match applies it: the whole shelf returns.
await refine(page).getByRole('combobox',{name:'Tag match',exact:true}).selectOption('all');
await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toBeEnabled();
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();
await expect(filtersButton(page,'Filters')).toBeVisible();
await expect.poll(()=>shelf(page).count()).toBeGreaterThan(2);
});
test('Refine › Prompts without tags on the shelf drops typed tags and Clear restores All',async({page})=>{
await page.goto('/prompt_manager/workspace#/library');await expect(shelf(page).first()).toBeVisible();
await filtersButton(page,'Filters').click();
await refine(page).getByRole('textbox',{name:'Tags',exact:true}).fill('cinematic');
await refine(page).getByRole('combobox',{name:'Tag match',exact:true}).selectOption('untagged');
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();
await expect(shelf(page)).toHaveCount(2);
await filtersButton(page,'Filters · 1').click();
await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('');
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();
await expect(filtersButton(page,'Filters')).toBeVisible();await expect.poll(()=>shelf(page).count()).toBeGreaterThan(2);
});
test('an older server that ignores the search shows an error with Retry, never an unfiltered list',async({page})=>{
await open(page);
// An un-restarted server answers without the echo.
await page.route('**/prompt_manager/vocabulary/tags?*',async route=>{
const response=await route.fetch();const body=await response.json();delete body.search;delete body.sort;
await route.fulfill({response,json:body});
});
await search(page).fill('light');
await expect(tagSurface(page).locator('.vocabulary-error')).toBeVisible();
await expect(rows(page)).toHaveCount(0);
await expect(tagSurface(page).getByRole('button',{name:'Retry',exact:true})).toBeVisible();
});
test('the search and sort fit 390px without a page scroll',async({page})=>{
await page.setViewportSize({width:390,height:844});await open(page);
const layout=await tagSurface(page).evaluate(node=>{
const tools=node.querySelector('search.vocabulary-tools'),field=tools.querySelector('.search-field'),select=tools.querySelector('select');
const box=node.getBoundingClientRect(),fieldBox=field.getBoundingClientRect(),selectBox=select.getBoundingClientRect();
return {inside:fieldBox.left>=box.left&&selectBox.right<=box.right+0.5,fullField:Math.round(fieldBox.width)===Math.round(tools.getBoundingClientRect().width)};
});
expect(layout).toEqual({inside:true,fullField:true});
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
});
// Artifact-only captures for the maintainer's quick check (no pixel assertions): a search with a sort, the Library on
// Untagged prompts and its Refine dialog.
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`D149 captures ${width} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});await open(page);
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
await sort(page).selectOption('prompts');await search(page).fill('i');await expect(status(page)).toHaveText(/^\d+ tags$/);
await page.mouse.move(0,0);
await captureCanonicalState(page,testInfo,`production-tags-search-${width}-${theme}`,'#workspace-main');
await untagged(page).getByRole('button',{name:'Browse',exact:true}).click();await expect(shelf(page)).toHaveCount(2);
await captureCanonicalState(page,testInfo,`production-library-untagged-${width}-${theme}`,'#workspace-main');
await filtersButton(page,'Filters · 1').click();await expect(refine(page)).toBeVisible();
await captureCanonicalState(page,testInfo,`production-refine-untagged-${width}-${theme}`,'dialog[open]');
});
+5 -51
View File
@@ -1,9 +1,9 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,d149Of,toolbarInventoryOf} from './vocabulary-canonical-comparison.mjs';
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs';
const route='/prompt_manager/workspace#/tags';
// Prompts per concept tag (app.js:71-165): first appearance order, Library Browse counts.
@@ -552,31 +552,6 @@ test('conflict and restore states keep the dialog and write nothing',async({page
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
});
// #415: a restore the page sees finish while Create tag is open keeps the dialog and its entries; the page is shown
// again from the restored data only once the dialog has closed.
test('a restore seen finishing keeps an open dialog and reloads the page after it closes',async({page,request})=>{
const saved=await backup(request);
await expect(await request.put('/prompt_manager/tags/mist',{data:{new_name:'fog'}})).toBeOK();
const restoreRun=await followRestore(page);const writes=trackWrites(page);await openPage(page);
await expect(page.locator('#activity-pill')).toHaveAttribute('href',SEEN_RESTORE_PILL);
await expect(tagRow(page,'fog')).toHaveCount(1);
const box=await openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag');
await field(box,'Tag name').fill('sunrise');
await expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'vocabulary.db',mimeType:'application/octet-stream',buffer:saved}}})).toBeOK();
await restoreRun.finish();
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
await expect(box).toBeVisible();await expect(field(box,'Tag name')).toHaveValue('sunrise');
await expect(tagRow(page,'fog')).toHaveCount(1);
await button(box,'Apply').click();
await expect(note(box)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was changed.');
expect(await actionNames(box)).toEqual(['Reload','Cancel','Apply']);
expect(tagNames(await snapshot(request))).not.toContain('sunrise');
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
await expect(tagRow(page,'mist')).toHaveCount(1);await expect(tagRow(page,'fog')).toHaveCount(0);
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
});
test('lost response is outcome-unknown',async({page,request})=>{
const writes=trackWrites(page);await openPage(page);
await page.route('**/prompt_manager/vocabulary/tags/*',async route=>{
@@ -786,15 +761,6 @@ async function openProductionTags(page,theme,motion) {
await expect(rows(page)).toHaveCount(manifest.tags.length);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
}
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf);
/** D149 donor: the concept's own page toolbar (Prompt library), whose search field and select the tag search reuses. */
async function conceptToolbar(page,theme) {
await page.goto('about:blank');await page.goto(`${concept}#library`);
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
const inventory=await page.locator('#main search.toolbar').evaluate(toolbarInventoryOf);
await page.goto('about:blank');await page.goto(`${concept}#tags`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
return inventory;
}
const tagToolbar=page=>page.locator('#workspace-main .two-col > section.surface:first-child > search.toolbar').evaluate(toolbarInventoryOf);
/** Breadcrumb, document title (D7: "Tags", only the concept suffix differs) and the keyboard focus ring on a row action. */
async function chromeOf(page,production) {
const scope=page.locator(production?'#workspace-main':'#main');
@@ -814,7 +780,7 @@ async function chromeOf(page,production) {
}
/** Concept rows are the concept's own sample tags; production appends the fixture-only tags after them (D4 creation order). */
function comparePage(reference,actual) {
const expected=mapVocabularyConcept(reference,{},d149Of(actual));
const expected=mapVocabularyConcept(reference);
const shared=expected.table.rows.length;
return {diff:diffInventory(expected,{...actual,table:{...actual.table,rows:actual.table.rows.slice(0,shared)}}),
extra:actual.table.rows.slice(shared).map(row=>row.tag)};
@@ -827,18 +793,13 @@ for(const [width,height] of SIZES)for(const theme of ['dark','light'])test(`tags
await openConceptTags(page,theme,motion);const reference=await pageOf(page,false);
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-tags-${width}-${theme}`,'#main');
const referenceChrome=await chromeOf(page,false);
const referenceTools=await conceptToolbar(page,theme);
await openProductionTags(page,theme,motion);const actual=await pageOf(page,true);
const actualTools=await tagToolbar(page);
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-tags-${width}-${theme}`,'#workspace-main');
result[motion]={reference,actual,...comparePage(reference,actual),referenceChrome,actualChrome:await chromeOf(page,true),
toolbarDiff:diffInventory(referenceTools,actualTools)};
result[motion]={reference,actual,...comparePage(reference,actual),referenceChrome,actualChrome:await chromeOf(page,true)};
}
const path=testInfo.outputPath('tags-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
await testInfo.attach('tags-geometry',{path,contentType:'application/json'});
for(const [motion,{reference,diff,extra,referenceChrome,actualChrome,toolbarDiff}] of Object.entries(result)) {
// D149 (#444): the tag search and sort carry the concept page toolbar's tokens.
expect.soft(toolbarDiff,`${motion} tag search and sort against the concept toolbar`).toEqual([]);
for(const [motion,{reference,diff,extra,referenceChrome,actualChrome}] of Object.entries(result)) {
expect.soft(referenceChrome,`${motion} concept chrome`).toMatchObject({breadcrumb:'Tags',title:'Tags',ring:{visible:true},linkRing:{outlineOffset:'3px'}});
expect.soft(actualChrome,`${motion} breadcrumb, title and focus ring`).toEqual(referenceChrome);
expect.soft(reference.heading.actions.buttons.map(item=>item.text),`${motion} concept heading actions`).toEqual(['+ Create tag','Auto-tag images']);
@@ -908,13 +869,6 @@ test('mutation check: the comparison reports a changed surface padding and a cha
expect(paths).toContain('surfaces.1.paddingLeft');
expect(paths).toContain('table.rows.0.actions.0');
expect(paths).toContain('table.rowActions.buttons.0.text');
// D149: a moved Untagged prompts row and a restyled tag search are reported too.
await page.addStyleTag({content:'.vocabulary-main .surface > .operation{margin-top:6px!important}.vocabulary-tools .search-field{border-radius:9px!important}'});
const moved=comparePage(reference,await pageOf(page,true)).diff.map(item=>item.path);
expect(moved).toContain('d149.offset');
const tools=await conceptToolbar(page,'dark');await openProductionTags(page,'dark','no-preference');
await page.addStyleTag({content:'.vocabulary-tools .search-field{border-radius:9px!important}'});
expect(diffInventory(tools,await tagToolbar(page)).map(item=>item.path)).toContain('field.borderTopLeftRadius');
// The dialog comparison is not vacuous either: a removed Cancel and a recolored field are reported.
const donors=await conceptDonors(page,'dark');
await openConceptTags(page,'dark','no-preference');await conceptDialogs.create(page);
+6 -10
View File
@@ -472,7 +472,7 @@ test('System is native: the concept shell, section navigation, sections and the
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2);
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'section.surface', 'div.save-row'] /* D156: Browsing & display, Viewer, Thumbnails */,
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'div.save-row'],
sources: ['div.notice', 'section.surface', 'div.save-row'],
integrations: ['section.surface', 'section.surface', 'div.save-row'],
maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'],
@@ -548,16 +548,12 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
const sidebar = page.locator('aside.sidebar');
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-label',
'nav#saved-nav', 'div.sidebar-label', 'nav#collections-nav', 'div.sidebar-bottom']);
'nav#saved-nav', 'div.sidebar-bottom']);
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
// D49: SAVED VIEWS without the concept's PROPOSED badge. D136: COLLECTIONS without it either, its label hidden
// and its nav empty while no collection exists.
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS', 'COLLECTIONS']);
await expect(sidebar.locator('.sidebar-label').nth(2)).toBeHidden();
// D49: SAVED VIEWS without the concept's PROPOSED badge; D28: still no COLLECTIONS.
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
await expect(page.locator('nav#saved-nav')).toHaveAttribute('aria-label', 'Saved views');
await expect(page.locator('nav#collections-nav')).toHaveAttribute('aria-label', 'Collections');
await expect(page.locator('nav#collections-nav > a')).toHaveCount(0);
await expect(page.locator('.new-label')).toHaveCount(0);
await expect(page.locator('#collections-nav, .new-label')).toHaveCount(0);
const primary = page.locator('nav#primary-nav');
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
@@ -584,7 +580,7 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
await page.setViewportSize({ width: 390, height: 844 });
await expect(navCount(page, 'library')).toBeHidden();
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(4);
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(3);
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
});
@@ -1,131 +0,0 @@
"""Owned, deterministic collections for the workspace collection workflows (#331).
Twenty-one PNGs under the output source: twelve listed members of
"Landscapes", eight of "Experiments" and one loose file in neither. Landscapes
also holds one member whose file does not exist (MC3: hidden, counted as
missing). One saved view reopens "Experiments" (D140). Members are added
through ``ImageCollectionOperations`` with favorites' keys
(``_favorite_root_key``, ``_favorite_relative_key``), as the routes add them.
"""
from __future__ import annotations
import os
import time
from pathlib import Path
from typing import TYPE_CHECKING
from aiohttp import web
from PIL import Image
from tests.browser.folder_trash import replace_folder
if TYPE_CHECKING:
from py.api import PromptManagerAPI
FOLDER = "collections"
LANDSCAPES = "Landscapes"
EXPERIMENTS = "Experiments"
LANDSCAPE_FILES = tuple(f"landscape_{index:02d}.png" for index in range(1, 13))
EXPERIMENT_FILES = tuple(f"experiment_{index:02d}.png" for index in range(1, 9))
LOOSE_FILE = "loose.png"
MISSING_FILE = "gone.png"
SAVED_VIEW = "Experiments view"
_TABLES = (
"image_collection_items",
"image_collections",
"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",
)
_IMAGES_STATE = {
"q": "",
"category": "",
"sort": "newest",
"source": "all",
"folder": "",
"media": "all",
"tags": [],
"favorites": False,
}
def _write_png(path: Path, shade: int, stamp: float) -> None:
with Image.new("RGB", (64, 48), (shade, 120, 255 - shade)) as image:
image.save(path)
os.utime(path, (stamp, stamp))
def seed_collections(api: PromptManagerAPI, root: Path) -> dict:
"""Replace this server's disposable rows and files with the two collections."""
# The API package needs the host stubs the server installs before importing it.
from py.api.images import _favorite_relative_key, _favorite_root_key
db = api.db
with db.model.get_connection() as conn:
for table in _TABLES:
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
replace_folder(root / name)
folder = root / "output" / FOLDER
folder.mkdir()
# Newest first in this order: Landscapes, then Experiments, then the loose file.
now = time.time()
ordered = (*LANDSCAPE_FILES, *EXPERIMENT_FILES, LOOSE_FILE)
for index, name in enumerate(ordered):
_write_png(folder / name, (index * 11) % 256, now - 60 - index * 60)
root_key = _favorite_root_key(list(api._get_all_output_dirs())[0])
members = {
LANDSCAPES: (*LANDSCAPE_FILES, MISSING_FILE),
EXPERIMENTS: EXPERIMENT_FILES,
}
ids = {}
for name, files in members.items():
added = db.add_to_collection(
name=name,
items=[
(root_key, _favorite_relative_key(f"{FOLDER}/{file}")) for file in files
],
)
ids[name] = added["collection"]["id"]
view = db.create_saved_view(
"images", SAVED_VIEW, {**_IMAGES_STATE, "collection": str(ids[EXPERIMENTS])}
)
api.invalidate_gallery_cache()
return {
"collections": {
LANDSCAPES: {"id": ids[LANDSCAPES], "count": 12, "missing": 1},
EXPERIMENTS: {"id": ids[EXPERIMENTS], "count": 8, "missing": 0},
},
"files": {
LANDSCAPES: [f"{FOLDER}/{file}" for file in LANDSCAPE_FILES],
EXPERIMENTS: [f"{FOLDER}/{file}" for file in EXPERIMENT_FILES],
"loose": f"{FOLDER}/{LOOSE_FILE}",
},
"saved_view": {"id": view["id"], "name": SAVED_VIEW},
}
def register_collections_commands(
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
) -> None:
"""Register the fixed seed command; no path or SQL comes from a request."""
@routes.post("/__test__/collections/seed")
async def seed(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else {}
if data != {} or request.query:
raise web.HTTPBadRequest()
manifest = await api._run_in_executor(seed_collections, api, root)
return web.json_response({"success": True, **manifest})
@@ -174,8 +174,6 @@ _TABLES = (
"execution_contexts",
"execution_prompt_snapshots",
"execution_context_texts",
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
@@ -195,10 +193,6 @@ _TABLES = (
)
# The concept's collections and the slice of its images each holds (#331).
CONCEPT_COLLECTIONS = (("Landscapes", slice(0, 12)), ("Experiments", slice(12, 20)))
def concept_starred(prompt_index: int, round_index: int) -> bool:
"""The concept's favorites (app.js ``starred``): each other prompt's first image."""
return round_index == 0 and prompt_index % 2 == 0
@@ -501,19 +495,6 @@ def seed_workspace_concept(
)
images.reverse()
runs.reverse()
# The concept's COLLECTIONS rows (app.js a336–339): "Landscapes" holds the
# first 12 images in the concept's order and "Experiments" the next 8, so
# the rows and counts compare as the concept shows them (#331 D136).
collections = {}
for name, members in CONCEPT_COLLECTIONS:
added = db.add_to_collection(
name=name,
items=[
(favorite_root, _favorite_relative_key(image["path"]))
for image in images[members]
],
)
collections[name] = added["collection"]["id"]
seedless = []
for number in range(seedless_images):
name = f"seedless_{number + 1:05}.png"
@@ -550,5 +531,4 @@ def seed_workspace_concept(
"images": images,
"runs": runs,
"tags": tags,
"collections": collections,
}
@@ -26,8 +26,6 @@ EDITOR_METADATA = {
"notes": "Keep the soft light.",
}
_TABLES = (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
@@ -78,8 +78,6 @@ LIMITED_SAMPLERS = 17
# More than one queue page (40), so Load more has a second page.
MAX_ITEMS = 45
_TABLES = (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
+2 -12
View File
@@ -56,8 +56,6 @@ def _valid_missing(value) -> bool:
# Unlinked copies enough to virtualize the Images grid (it renders all up to 120).
MAX_EXTRA_IMAGES = 160
_TABLES = (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
@@ -194,16 +192,9 @@ def tagging_snapshot(api: PromptManagerAPI) -> dict:
def register_tagging_commands(
routes: web.RouteTableDef,
api: PromptManagerAPI,
root: Path,
model,
on_reset=lambda: None,
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path, model
) -> None:
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input.
``on_reset`` runs first on every tagging reset (the source-change undo).
"""
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input."""
# The fake download (``/__test__/tagging/download``): waits on ``event``,
# then answers ``result``. Every reset sets the event and puts back the
# model's raising default, so no test can leave a worker hanging.
@@ -237,7 +228,6 @@ def register_tagging_commands(
raise web.HTTPBadRequest()
if not 0 <= extra <= MAX_EXTRA_IMAGES:
raise web.HTTPBadRequest()
on_reset()
model.unavailable = set()
model.missing_packages = {}
model.suggestions = None
@@ -112,8 +112,6 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
"""Replace only the owned browser harness records and media roots."""
with api.db.model.get_connection() as conn:
for table in (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
@@ -83,8 +83,6 @@ UNUSED_TAG = "unused_vocabulary"
MAX_EXTRA_TAGS = 300
MAX_EXTRA_CATEGORIES = 200
_TABLES = (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
+9 -13
View File
@@ -263,18 +263,15 @@ test.describe('public chronological history with the actual acknowledged host br
});
});
// #403: the graph cannot show a dynamic positive's raw text; a cached job takes it from the execution it reuses.
test('a cached dynamic positive takes its raw text from the execution it reuses and joins the Library scope', async ({ execution }) => {
const text = `dynamic positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
test('a cached dynamic unknown positive stays readable by run UUID and outside the known raw Library scope', async ({ execution }) => {
const text = `unknown positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
const first = await submit(execution.request, graph);await history(execution.request, first);const known = (await capture(execution, first)).contexts[0];
const second = await submit(execution.request, graph);await history(execution.request, second);const reused = await capture(execution, second);const cached = reused.contexts[0];
expect(cached.evidence.positive.effective.source).toBe('host_cache_output');expect(cached.positive.effective).toBe(known.positive.effective);
expect(cached.positive.raw).toBe(known.positive.raw);expect(cached.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(cached.positive_prompt_id).toBe(known.positive_prompt_id);
const second = await submit(execution.request, graph);await history(execution.request, second);const unknown = (await capture(execution, second)).contexts[0];
expect(unknown.positive.raw).toBeNull();expect(unknown.positive.effective).toBe(known.positive.effective);
const result = await readExecutionRuns(execution.databasePath, second), runId = result.rows[0].run_id;
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBe(known.positive.raw);
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBeNull();
const response = await execution.request.get(`/prompt_manager/library/${known.positive_prompt_id}/history`);expect(response.ok()).toBe(true);const window = await response.json();
expect(window.recorded_run_count).toBe(2);expect(window.items.map(item => item.run_id)).toContain(runId);
expect(window.recorded_run_count).toBe(1);expect(window.items.map(item => item.run_id)).not.toContain(runId);
});
test('actual PM A,A,B jobs persist two combinations and one identical positive snapshot', async ({ execution }) => {
@@ -372,7 +369,7 @@ test('successful source capture survives downstream failure and real interruptio
}
});
test('cached dynamic raw comes from the reused execution while literal affixes and effective cache text survive (#403)', async ({ execution }) => {
test('cached dynamic raw stays unknown while literal affixes and effective cache text survive', async ({ execution }) => {
const graph = recipeGraph({ dynamic: true, text: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ' });
const first = await submit(execution.request, graph);
await history(execution.request, first);
@@ -380,9 +377,8 @@ test('cached dynamic raw comes from the reused execution while literal affixes a
const second = await submit(execution.request, graph);
await history(execution.request, second);
const value = (await capture(execution, second)).contexts[0];
expect(value.positive).toEqual({ raw: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
expect(value.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(value.evidence.positive.prepend.source).toBe('graph_literal');
expect(value.positive).toEqual({ raw: null, prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
expect(value.evidence.positive.raw.provenance).toBe('unknown');
expect(value.evidence.positive.effective.source).toBe('host_cache_output');
});
+2 -20
View File
@@ -287,14 +287,6 @@ async function capturedContext(execution, id) {
return result.captures[result.rows[0].run_id].contexts[0];
}
// #403: the prompt's history "tracked runs", once every job's capture reached SQLite.
async function expectTrackedRuns(execution, promptId, expected) {
await expect.poll(async () => {
const response = await execution.request.get(`/prompt_manager/library/${promptId}/history`);
return response.ok() ? (await response.json()).recorded_run_count : null;
}, { timeout: 10000, message: 'history tracked runs equal the Library run count' }).toBe(expected);
}
async function firstRun(execution, page, testCase) {
const prefix = `textlink/${randomUUID()}`;
const built = await build(page, { ...testCase, prefix });
@@ -317,8 +309,6 @@ for (const kind of ['PromptManager', 'PromptManagerFocused', 'PromptManagerSplit
const counts = await runCounts(execution, first.context);
expect(counts.positive).toBe(3);
if (pair) expect(counts).toMatchObject({ negative: 0, pair: 3 });
// #403: the cached jobs are tracked runs of the prompt too
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
});
}
@@ -339,7 +329,6 @@ test.describe('cached runs in one renderer (#347)', () => {
else expect(again.cached).toContain(first.promptNode);
}
expect((await runCounts(execution, first.context)).positive).toBe(3);
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
} finally { await setCache(execution, 'RAM_PRESSURE'); }
});
}
@@ -363,9 +352,6 @@ test.describe('cached runs in one renderer (#347)', () => {
const bContext = await capturedContext(execution, second.id);
expect(bContext.positive.raw).toBe(b);
expect((await runCounts(execution, bContext)).positive).toBe(1);
// #403: the third job reuses A's execution, not the newest (B's)
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
await expectTrackedRuns(execution, bContext.positive_prompt_id, 1);
} finally { await setCache(execution, 'RAM_PRESSURE'); }
});
@@ -404,14 +390,10 @@ test.describe('cached runs in one renderer (#347)', () => {
} });
expect(rewired.cached).toContain(first.promptNode);
expect((await runCounts(execution, first.context)).positive).toBe(1);
// #403: a cached job's history takes the raw text of the execution it reuses and names the prompt
// The history capture of a cached job is unchanged: effective text from the cache, no stored-prompt hint
const cachedContext = await capturedContext(execution, (await queueAgain(execution, first, { seed: 3 })).id);
expect(cachedContext.positive.effective).toBe(first.context.positive.effective);
expect(cachedContext.positive.raw).toBe(first.context.positive.raw);
expect(cachedContext.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(cachedContext.positive_prompt_id).toBe(first.context.positive_prompt_id);
expect(cachedContext.positive_prompt_id).toBeNull();
expect((await runCounts(execution, first.context)).positive).toBe(2);
// The rewired job used the prompt as its negative: neither number counts it
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
});
});
+2 -2
View File
@@ -36,11 +36,11 @@ async function watchHost(page,id,reset=true) {
}
const restore=page=>page.evaluate(async()=>{const {app}=await import('/scripts/app.js');Object.assign(app,window.inspectOriginals);});
/** Chooses `file` on the Inspect page, which opens the shared viewer on its local record (D155, #448). */
/** Chooses `file` on the Inspect page and opens the shared viewer on its local record. */
async function inspect(workspace,{file,positive,shown}) {
await workspace.locator('#local-file').setInputFiles(file());
const open=workspace.locator('#workspace-main .heading-actions').getByRole('button',{name:'Open file metadata',exact:true});
await expect(open).toHaveAttribute('aria-disabled','false');
await expect(open).toHaveAttribute('aria-disabled','false');await open.click();
const rail=workspace.locator('dialog#viewer-dialog[open] #viewer-details');
await expect(rail.locator('.prompt-section.positive p.prompt-text')).toHaveText(positive);
await expect(rail.locator('.prompt-section.negative p.prompt-text')).toHaveText(shown);
-1
View File
@@ -121,7 +121,6 @@
"POST /prompt_manager/images/generate-thumbnails",
"POST /prompt_manager/images/generate-thumbnails/progress",
"POST /prompt_manager/images/link",
"POST /prompt_manager/images/rescope",
"POST /prompt_manager/images/reveal",
"POST /prompt_manager/jobs/autotag",
"POST /prompt_manager/jobs/duplicates",
+3 -17
View File
@@ -64,30 +64,18 @@ test("infinite scrolling is off by default and must be a boolean", () => {
test("the workspace keys have their own defaults and validation", () => {
const { WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES } = require("../../web/js/gallery-settings.js");
// #451: Images opens in Masonry unless the browser chose otherwise.
assert.equal(DEFAULT_SETTINGS.workspaceLayout, "masonry");
assert.equal(DEFAULT_SETTINGS.workspaceLayout, "grid");
assert.equal(DEFAULT_SETTINGS.workspaceDensity, "comfortable");
assert.deepEqual(WORKSPACE_LAYOUTS, ["grid", "list", "masonry"]);
assert.deepEqual(WORKSPACE_DENSITIES, ["comfortable", "large", "compact"]);
assert.equal(normalizeGallerySettings({ workspaceLayout: "masonry" }).workspaceLayout, "masonry");
assert.equal(normalizeGallerySettings({ workspaceLayout: "grid" }).workspaceLayout, "grid");
assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "masonry");
assert.equal(normalizeGallerySettings(null).workspaceLayout, "masonry");
assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "grid");
assert.equal(normalizeGallerySettings({ workspaceDensity: "large" }).workspaceDensity, "large");
assert.equal(normalizeGallerySettings({ workspaceDensity: 3 }).workspaceDensity, "comfortable");
// The legacy gallery's own view mode is separate: it has no masonry.
assert.equal(normalizeGallerySettings({ defaultViewMode: "masonry" }).defaultViewMode, "grid");
});
// #450 (D156): the viewer's default view, Centered unless Full screen was chosen.
test("the viewer open mode defaults to centered and accepts only its two values", () => {
const { VIEWER_OPEN_MODES } = require("../../web/js/gallery-settings.js");
assert.deepEqual(VIEWER_OPEN_MODES, ["centered", "fullscreen"]);
assert.equal(DEFAULT_SETTINGS.viewerOpenMode, "centered");
assert.equal(normalizeGallerySettings({ viewerOpenMode: "fullscreen" }).viewerOpenMode, "fullscreen");
for (const value of ["full", true, 1, null]) assert.equal(normalizeGallerySettings({ viewerOpenMode: value }).viewerOpenMode, "centered");
});
// #330 S13: the legacy gallery's Page options edit only the page-only keys (PAGE_OPTION_KEYS); System › Appearance owns the rest, so a Save
// or a Reset from the legacy page keeps every other stored key.
function legacyGallery(stored, controls, extra = {}) {
@@ -121,8 +109,7 @@ const PAGE_OPTION_CONTROLS = () => ({
test("a legacy gallery Save keeps the workspace keys and every other stored key", () => {
const { manager, saved } = legacyGallery({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1,
imageQuality: "high", showImageInfo: false, videoMute: false, thumbnailPromptDismissed: "sig", defaultViewMode: "list",
viewerOpenMode: "fullscreen" },
imageQuality: "high", showImageInfo: false, videoMute: false, thumbnailPromptDismissed: "sig", defaultViewMode: "list" },
PAGE_OPTION_CONTROLS());
manager.saveSettings();
const result = saved();
@@ -133,7 +120,6 @@ test("a legacy gallery Save keeps the workspace keys and every other stored key"
assert.equal(result.showImageInfo, false);
assert.equal(result.videoMute, false);
assert.equal(result.thumbnailPromptDismissed, "sig");
assert.equal(result.viewerOpenMode, "fullscreen");
// Page options no longer edit the default view (S13 G5), so a stored one stays.
assert.equal(result.defaultViewMode, "list");
// The page-only keys come from the dialog.
-1
View File
@@ -10,7 +10,6 @@ export function mountedDom() {
// As in the DOM, a string argument becomes a text node.
static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;}
append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}}
insertBefore(node,before){node.parent=this;const index=this.children.indexOf(before);this.children.splice(index<0?this.children.length:index,0,node);return node;}
prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);}
remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;}
-313
View File
@@ -1,313 +0,0 @@
// Collections (#331 D136–D140): the client store, the route hash, the names, every user-facing string and the
// mapping of the routes' error codes.
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/collections.mjs');
const FILE = Object.freeze({ root_index: 0, root_scope: 'a'.repeat(64), path: 'grids/one.png' });
function apiError(status, code) {
return Object.assign(new Error(code || `HTTP ${status}`), { status, code: code || null });
}
function fakeApi() {
const calls = [];
const pending = [];
const api = {
calls,
get(url, options) {
calls.push(['GET', url, options?.cache]);
if (url.includes('/membership?')) return api.membershipResult ?? Promise.resolve({ success: true, collection_ids: [2] });
return new Promise((resolve, reject) => pending.push({ resolve, reject }));
},
post(url, body) {
calls.push(['POST', url, body]);
return api.postResult ?? Promise.resolve({ success: true, collection: { id: 5, name: 'Grids' }, created: true,
added: body.items.length, already: 0, not_found: 0 });
},
patch(url, body) {
calls.push(['PATCH', url, body]);
return api.patchResult ?? Promise.resolve({ success: true, collection: { id: 2, name: body.name } });
},
del(url, options) {
calls.push(['DELETE', url, options?.body]);
return api.deleteResult ?? Promise.resolve(options?.body
? { success: true, collection: { id: options.body.collection_id, name: 'Portraits' }, removed: options.body.items.length }
: { success: true, deleted: true });
},
};
return { api, pending };
}
const LISTED = { success: true, collections: [
{ id: 4, name: 'Experiments', count: 8, missing: 0 },
{ id: 2, name: 'Landscapes', count: 12, missing: 1 },
] };
/** A store whose first read answered with `data`; the test's own timer seam replaces the 300 ms count timer. */
async function readyStore(data = LISTED) {
const { createCollections } = await source;
const { api, pending } = fakeApi();
const timers = [];
const controller = new AbortController();
const store = createCollections(api, controller.signal, {
setTimer: (run, delay) => { timers.push({ run, delay, cleared: false }); return timers.length; },
clearTimer: id => { if (timers[id - 1]) timers[id - 1].cleared = true; },
});
store.load();
pending.shift().resolve(data);
await store.settled();
return { store, api, pending, timers, controller };
}
test('the route hash, the name rule and the limits', async () => {
const { collectionHash, normalizeCollectionName, MAX_COLLECTION_NAME_LENGTH, MAX_COLLECTION_ITEMS,
COLLECTION_COUNT_DELAY_MS } = await source;
assert.equal(collectionHash(12), '#/collection/12');
assert.throws(() => collectionHash(0), TypeError);
assert.throws(() => collectionHash('3'), TypeError);
assert.equal(MAX_COLLECTION_NAME_LENGTH, 100);
assert.equal(MAX_COLLECTION_ITEMS, 500);
assert.equal(COLLECTION_COUNT_DELAY_MS, 300);
// Trimmed, each run of whitespace one space, 1 to 100 characters, no control characters (design §1).
assert.equal(normalizeCollectionName(' LoRa testing   grids '), 'LoRa testing grids');
assert.equal(normalizeCollectionName('x'.repeat(100)), 'x'.repeat(100));
for (const bad of ['', ' ', 'x'.repeat(101), 'bad\u0007name', 7, null, undefined]) {
assert.equal(normalizeCollectionName(bad), null, String(bad));
}
// A tab or newline is whitespace, collapsed rather than refused.
assert.equal(normalizeCollectionName('a\tb\nc'), 'a b c');
});
test('the listing keeps usable rows in the server order, with counts only when they are counts', async () => {
const { parseCollections, parseCollection } = await source;
assert.deepEqual(parseCollections({ success: true, collections: [
{ id: 3, name: 'b', count: 2, missing: 0 },
{ id: 0, name: 'bad id' },
{ id: 1, name: ' ', count: 1, missing: 0 },
{ id: 7, name: 'A', count: null, missing: null },
{ id: 9, name: 'c', count: -1, missing: 1.5 },
] }), [
{ id: 3, name: 'b', count: 2, missing: 0 },
{ id: 7, name: 'A', count: null, missing: null },
{ id: 9, name: 'c', count: null, missing: null },
]);
assert.equal(parseCollection(null), null);
assert.throws(() => parseCollections({ success: true }), TypeError);
});
test('every user-facing string of the design (§7.4, §8)', async () => {
const c = await source;
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 0, notFound: 0 }), 'Added 1 image to “X”.');
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 3, already: 0, notFound: 0 }), 'Added 3 images to “X”.');
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 1, notFound: 0 }),
'Added 1 image to “X”. 1 was already in it.');
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 2, already: 3, notFound: 1 }),
'Added 2 images to “X”. 3 were already in it. 1 could not be found.');
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 0, notFound: 2 }),
'Added 1 image to “X”. 2 could not be found.');
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 0, already: 4, notFound: 0 }),
'All selected images were already in “X”.');
assert.equal(c.FILES_NOT_FOUND_TOAST, 'None of the selected images could be found. Refresh the images and try again.');
assert.equal(c.SOURCE_CHANGED_TOAST, 'The sources changed. Refresh the images and try again.');
assert.equal(c.ADD_FAILED_TOAST, 'The images could not be added. Try again.');
assert.equal(c.removedMessage('X', 1), 'Removed 1 image from “X”.');
assert.equal(c.removedMessage('X', 4), 'Removed 4 images from “X”.');
assert.equal(c.REMOVE_FAILED_TOAST, 'The images could not be removed. Try again.');
assert.equal(c.COLLECTION_GONE_TOAST, 'That collection no longer exists.');
assert.deepEqual([c.COLLECTION_NOUNS.title, c.COLLECTION_NOUNS.label, c.COLLECTION_NOUNS.deleteTitle],
['Edit collection', 'Collection name', 'Delete collection']);
assert.equal(c.COLLECTION_NOUNS.renamed, 'Collection renamed.');
assert.equal(c.RENAMED_TOAST, 'Collection renamed.');
assert.equal(c.nameTakenMessage('LoRa'), 'A collection named “LoRa” already exists.');
assert.equal(c.RENAME_FAILED_TOAST, 'The collection could not be renamed. Try again.');
// The stored count: listed members plus missing ones.
assert.equal(c.deleteQuestion({ name: 'X', count: 1, missing: 0 }),
'Delete “X”? Its 1 image stays where it is; only the collection is removed.');
assert.equal(c.deleteQuestion({ name: 'X', count: 3, missing: 1 }),
'Delete “X”? Its 4 images stay where they are; only the collection is removed.');
assert.equal(c.deleteQuestion({ name: 'X', count: null, missing: null }),
'Delete “X”? Its images stay where they are; only the collection is removed.');
assert.equal(c.COLLECTION_NOUNS.deleteText({ name: 'X', count: 2, missing: 0 }), c.deleteQuestion({ name: 'X', count: 2, missing: 0 }));
assert.equal(c.DELETED_TOAST, 'Collection deleted.');
assert.equal(c.DELETE_FAILED_TOAST, 'The collection could not be deleted. Try again.');
assert.equal(c.DELETED_PAGE_TEXT, 'This collection no longer exists.');
assert.equal(c.OPEN_IMAGES_LINK, 'Open Images');
assert.equal(c.VIEW_COLLECTION_DELETED, 'The collection this view uses was deleted.');
assert.equal(c.LIST_FAILED_TEXT, 'Collections could not be loaded. Your saved data has not changed.');
assert.equal(c.missingNote(1), '1 image in this collection is not in your current sources.');
assert.equal(c.missingNote(5), '5 images in this collection are not in your current sources.');
for (const none of [0, null, undefined, -1, 1.5]) assert.equal(c.missingNote(none), '');
assert.equal(c.TOO_MANY_TOAST, 'Select at most 500 images.');
assert.deepEqual([c.ADD_DIALOG_TITLE, c.ADD_DIALOG_LABEL, c.ADD_DIALOG_ACTION],
['Add selected images to collection', 'Collection name', 'Add']);
assert.equal(c.COLLECTIONS_UPDATED_TOAST, 'Collections updated.');
assert.equal(c.UPDATE_FAILED_TEXT, 'The collections could not be updated. Try again.');
assert.equal(c.MEMBERSHIP_LOADING_TEXT, 'Loading collections…');
assert.equal(c.MEMBERSHIP_FAILED_TEXT, 'Collections could not be loaded.');
assert.equal(c.NO_COLLECTIONS_TEXT, 'No collections yet. Name one below.');
});
test('a write failure maps the routes\' error codes to their strings', async () => {
const { writeFailure, renameFailure, isCollectionGone, COLLECTION_GONE_TOAST, SOURCE_CHANGED_TOAST,
FILES_NOT_FOUND_TOAST, ADD_FAILED_TOAST, RENAME_FAILED_TOAST } = await source;
assert.equal(writeFailure(apiError(404, 'collection-not-found'), ADD_FAILED_TOAST), COLLECTION_GONE_TOAST);
assert.equal(writeFailure(apiError(409, 'source-changed'), ADD_FAILED_TOAST), SOURCE_CHANGED_TOAST);
assert.equal(writeFailure(apiError(404, 'files-not-found'), ADD_FAILED_TOAST), FILES_NOT_FOUND_TOAST);
for (const other of [apiError(500), apiError(400), apiError(404), new TypeError('x'), null]) {
assert.equal(writeFailure(other, ADD_FAILED_TOAST), ADD_FAILED_TOAST);
}
assert.equal(isCollectionGone(apiError(404, 'collection-not-found')), true);
assert.equal(isCollectionGone(apiError(404)), false);
// The Edit dialog: a taken name stays in the dialog; a gone collection closes it; anything else keeps it open.
assert.deepEqual(renameFailure('LoRa')(apiError(409, 'name-taken')), { field: 'A collection named “LoRa” already exists.' });
assert.deepEqual(renameFailure('LoRa')(apiError(404, 'collection-not-found')), { toast: COLLECTION_GONE_TOAST, close: true });
assert.deepEqual(renameFailure('LoRa')(apiError(500)), { toast: RENAME_FAILED_TOAST });
});
test('the store reports loads, keeps the last list after a failed read, and a newer read supersedes', async () => {
const { createCollections, COLLECTIONS_URL } = await source;
const { api, pending } = fakeApi();
const store = createCollections(api);
assert.equal(store.status, 'loading');
const seen = [];
store.subscribe(next => seen.push([next.status, next.collections.map(item => item.id)]));
store.load();
const settled = store.settled();
pending[0].resolve(LISTED);
assert.equal((await settled).status, 'ready');
assert.deepEqual(api.calls[0], ['GET', COLLECTIONS_URL, 'no-store']);
assert.deepEqual(store.find(2), { id: 2, name: 'Landscapes', count: 12, missing: 1 });
assert.equal(store.find(3), null);
assert.deepEqual(store.collections.map(item => item.name), ['Experiments', 'Landscapes']);
store.load();
pending[1].reject(new Error('offline'));
await store.settled();
assert.equal(store.status, 'error');
assert.deepEqual(store.collections.map(item => item.id), [4, 2]);
store.load();
store.load();
pending[3].resolve({ success: true, collections: [{ id: 8, name: 'New', count: 0, missing: 0 }] });
pending[2].resolve(LISTED);
await store.settled();
assert.deepEqual(store.collections.map(item => item.id), [8]);
assert.deepEqual(seen.at(-1), ['ready', [8]]);
// A malformed listing is a failed read.
store.load();
pending[4].resolve({ success: true });
await store.settled();
assert.equal(store.status, 'error');
});
test('adding by name or id sends only the file identity and re-reads the counts 300 ms after the last change', async () => {
const { store, api, pending, timers } = await readyStore();
const { COLLECTION_ITEMS_URL } = await source;
const result = await store.add({ name: ' Grids ' }, [{ ...FILE, title: 'ignored', favorite: true }]);
assert.deepEqual(api.calls.at(-1), ['POST', COLLECTION_ITEMS_URL, { name: 'Grids', items: [FILE] }]);
assert.deepEqual(result, { collection: { id: 5, name: 'Grids' }, created: true, added: 1, already: 0, notFound: 0 });
// The new collection is listed at once (its count arrives with the re-read), in name order.
assert.deepEqual(store.collections.map(item => [item.id, item.count]), [[4, 8], [5, null], [2, 12]]);
api.postResult = Promise.resolve({ success: true, collection: { id: 2, name: 'Landscapes' }, created: false, added: 0,
already: 1, not_found: 1 });
const again = await store.add({ collectionId: 2 }, [FILE, { ...FILE, path: 'b.png' }]);
assert.deepEqual(api.calls.at(-1)[2], { collection_id: 2, items: [FILE, { ...FILE, path: 'b.png' }] });
assert.deepEqual(again, { collection: { id: 2, name: 'Landscapes' }, created: false, added: 0, already: 1, notFound: 1 });
// One trailing re-read for the run of writes.
assert.deepEqual(timers.map(timer => [timer.delay, timer.cleared]), [[300, true], [300, false]]);
const reads = api.calls.filter(call => call[0] === 'GET').length;
timers.at(-1).run();
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
pending.shift().resolve(LISTED);
await store.settled();
});
test('a write is refused before sending when its target or files are not valid', async () => {
const { store, api } = await readyStore();
const sent = api.calls.length;
const many = Array.from({ length: 501 }, (_, index) => ({ ...FILE, path: `${index}.png` }));
for (const [target, items] of [
[{ name: '' }, [FILE]], [{ name: 'x'.repeat(101) }, [FILE]], [{ collectionId: 0 }, [FILE]], [{}, [FILE]],
[{ name: 'a', collectionId: 2 }, [FILE]], [{ collectionId: 2 }, []], [{ collectionId: 2 }, many],
[{ collectionId: 2 }, [{ ...FILE, root_index: -1 }]], [{ collectionId: 2 }, [{ ...FILE, path: '' }]],
[{ collectionId: 2 }, [{ ...FILE, root_scope: 3 }]], [{ collectionId: 2 }, 'a.png'],
]) {
await assert.rejects(store.add(target, items), TypeError);
}
await assert.rejects(store.remove(0, [FILE]), TypeError);
await assert.rejects(store.remove(2, []), TypeError);
await assert.rejects(store.rename(2, ' '), TypeError);
await assert.rejects(store.delete(-1), TypeError);
await assert.rejects(store.membership({ root_index: 'x', root_scope: 'a', path: 'p' }), TypeError);
assert.equal(api.calls.length, sent);
});
test('a write to a collection that is gone re-reads the list at once and rejects with the error', async () => {
const { store, api, pending } = await readyStore();
const { isCollectionGone } = await source;
api.postResult = Promise.reject(apiError(404, 'collection-not-found'));
const reads = api.calls.filter(call => call[0] === 'GET').length;
await assert.rejects(store.add({ collectionId: 2 }, [FILE]), error => isCollectionGone(error));
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
pending.shift().resolve({ success: true, collections: [LISTED.collections[0]] });
await store.settled();
assert.equal(store.find(2), null);
// Another failure re-reads nothing.
api.deleteResult = Promise.reject(apiError(500));
await assert.rejects(store.remove(4, [FILE]), error => error.status === 500);
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
});
test('remove sends the collection and files; membership reads one file\'s collections', async () => {
const { store, api, timers } = await readyStore();
const { COLLECTION_ITEMS_URL, COLLECTION_MEMBERSHIP_URL } = await source;
assert.deepEqual(await store.remove(2, [FILE]), { collection: { id: 2, name: 'Portraits' }, removed: 1 });
assert.deepEqual(api.calls.at(-1), ['DELETE', COLLECTION_ITEMS_URL, { collection_id: 2, items: [FILE] }]);
assert.equal(timers.length, 1);
assert.deepEqual(await store.membership(FILE), [2]);
const [method, url] = api.calls.at(-1);
assert.equal(method, 'GET');
const query = new URL(url, 'http://local');
assert.equal(query.pathname, COLLECTION_MEMBERSHIP_URL);
assert.deepEqual(Object.fromEntries(query.searchParams), { root_index: '0', root_scope: FILE.root_scope, path: FILE.path });
api.membershipResult = Promise.resolve({ success: true, collection_ids: [1, 'x'] });
await assert.rejects(store.membership(FILE), TypeError);
});
test('rename and delete change the listed row at once and re-read the counts', async () => {
const { store, api, timers } = await readyStore();
const { COLLECTIONS_URL } = await source;
const renamed = await store.rename(2, ' A grids ');
assert.deepEqual(api.calls.at(-1), ['PATCH', `${COLLECTIONS_URL}/2`, { name: 'A grids' }]);
assert.deepEqual(renamed, { id: 2, name: 'A grids' });
// The row keeps its counts and moves to its new place in name order.
assert.deepEqual(store.collections.map(item => [item.id, item.name, item.count]), [[2, 'A grids', 12], [4, 'Experiments', 8]]);
assert.equal(await store.delete(4), 4);
assert.deepEqual(api.calls.at(-1), ['DELETE', `${COLLECTIONS_URL}/4`, undefined]);
assert.deepEqual(store.collections.map(item => item.id), [2]);
// Already gone (another tab): the outcome the user asked for.
api.deleteResult = Promise.reject(apiError(404, 'collection-not-found'));
assert.equal(await store.delete(2), 2);
assert.deepEqual(store.collections, []);
api.patchResult = Promise.reject(apiError(409, 'name-taken'));
await assert.rejects(store.rename(9, 'Taken'), error => error.code === 'name-taken');
assert.equal(timers.filter(timer => !timer.cleared).length, 1);
});
test('a disposed store stops its count timer, drops a read in flight and reads no more', async () => {
const { store, timers, controller, api, pending } = await readyStore();
await store.rename(2, 'Kept');
const seen = [];
store.subscribe(next => seen.push(next));
store.load();
controller.abort();
assert.equal(timers.at(-1).cleared, true);
pending.shift().resolve(LISTED);
await new Promise(resolve => setImmediate(resolve));
assert.equal(seen.filter(next => next.status === 'ready').length, 0);
const sent = api.calls.length;
await store.load();
assert.equal(api.calls.length, sent);
});
+5 -341
View File
@@ -209,18 +209,16 @@ test('a branch named by recorded run history shows the Recorded run badge', asyn
function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) {
return {...record(), imageTags, library};
}
test('renders current image tags before linked tags, each without repeats within its source', async () => {
test('renders current image tags before linked tags without duplicates', async () => {
const {inspectorTagChips} = await load();
assert.equal(typeof inspectorTagChips, 'function', 'inspectorTagChips is exported');
// D154 amends D88: a linked tag that is also an image tag stays in its own labelled section.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal', 'Dunes']}), {filterable:true}),
[{name:'dunes', filter:true, source:'image'}, {name:'minimal', filter:true, source:'image'},
{name:'Minimal', filter:true, source:'linked'}, {name:'warm_light', filter:true, source:'linked'}, {name:'editorial', filter:true, source:'linked'}]);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}),
[{name:'dunes', filter:true}, {name:'minimal', filter:true}, {name:'warm_light', filter:true}, {name:'editorial', filter:true}]);
// D21's rule still decides the linked chips: a recipe's own tags stay plain, an image tag stays a filter.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['Editorial']}, {status:'linked', tags:['editorial', 'cover'], tags_scope:'pair'}), {filterable:true}),
[{name:'Editorial', filter:true, source:'image'}, {name:'editorial', filter:false, source:'linked'}, {name:'cover', filter:false, source:'linked'}]);
[{name:'Editorial', filter:true}, {name:'cover', filter:false}]);
// An unlinked file shows only its own tags; a facet-less record (history, local) only the linked ones.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true, source:'image'}]);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true}]);
assert.deepEqual(inspectorTagChips(tagged(null), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:[]}, {status:'ambiguous', tags:['x'], tags_scope:'positive'}), {filterable:true}), []);
});
@@ -238,61 +236,6 @@ test('none and unavailable render no image chips', async () => {
}
});
// #449 (D154): the image's own tags and the linked prompt's or recipe's tags each get a labelled section after the
// prompt blocks, so per-image tags never read as part of the negative prompt.
function renderVariant(record, variant, options = {}) {
const f = mountedDom(), scope = createScope();
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, ...options});
view.setState({loading:false, error:false, record});
return {f, done() {view.dispose(); scope.dispose(); f.restore();}};
}
const tagSections = f => f.walk(f.container, n => n.tagName === 'section' && /(^| )tag-section( |$)/.test(n.className ?? ''));
function sectionSummary(f, section) {
const label = f.walk(section, n => /(^| )role-label( |$)/.test(n.className ?? ''))[0];
const chips = f.walk(section, n => /(^| )chip( |$)/.test(n.className ?? ''));
return {className:section.className, name:section.getAttribute('aria-label'), label:label?.textContent, chips:chips.map(chip => `${chip.tagName}:${chip.textContent}`)};
}
test('chips name their source: the image or the linked prompt', async () => {
const {inspectorTagChips, tagSectionLabel} = await load();
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}).map(chip => chip.source),
['image', 'image', 'linked', 'linked', 'linked']);
assert.equal(tagSectionLabel('image', {tags_scope:'pair'}), 'Image tags');
assert.equal(tagSectionLabel('linked', {tags_scope:'positive'}), 'Prompt tags');
assert.equal(tagSectionLabel('linked', {tags_scope:'pair'}), 'Recipe tags');
});
test('image tags and linked tags render in their own labelled sections', async () => {
for (const [variant, options, tag] of [['panel', {onTagFilter() {}}, 'button'], ['full', {}, 'span']]) {
const {f, done} = renderVariant(tagged({state:'current', tags:['dunes', 'minimal']}), variant, options);
try {
const sections = tagSections(f).map(section => sectionSummary(f, section));
assert.deepEqual(sections, [
{className:'tag-section image-tags', name:'Image tags', label:'Image tags · 2', chips:[`${tag}:dunes`, `${tag}:minimal`]},
{className:'tag-section linked-tags', name:'Prompt tags', label:'Prompt tags · 3', chips:[`${tag}:Minimal`, `${tag}:warm_light`, `${tag}:editorial`]},
], variant);
// No bare chip row follows the prompt blocks any more.
const inner = f.container.children.find(n => n.tagName === 'div');
assert.equal(inner.children.filter(n => n.className === 'tags').length, 0, variant);
} finally { done(); }
}
});
test('a recipe names its tags and the context names the regions', async () => {
const {f, done} = renderVariant(tagged({state:'current', tags:['dunes']}, {status:'linked', tags:['cover'], tags_scope:'pair'}), 'panel', {promptContext:'Current file'});
try {
assert.deepEqual(tagSections(f).map(section => sectionSummary(f, section)), [
{className:'tag-section image-tags', name:'Current file image tags', label:'Image tags · 1', chips:['span:dunes']},
{className:'tag-section linked-tags', name:'Current file recipe tags', label:'Recipe tags · 1', chips:['span:cover']},
]);
} finally { done(); }
});
test('an empty tag source draws no section', async () => {
let view = renderVariant(tagged(null), 'full');
try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Prompt tags · 3']); } finally { view.done(); }
view = renderVariant(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), 'full');
try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Image tags · 1']); } finally { view.done(); }
view = renderVariant(tagged({state:'none', tags:[]}, {status:'none', tags:[], tags_scope:null}), 'full');
try { assert.equal(tagSections(view.f).length, 0); } finally { view.done(); }
});
// LI-4 (D117): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata
// page's COPY ALL (web/metadata.html copyAllData). The legacy template is read from that page itself and run on the
// legacy parse, so the summary's text is the legacy page's, line for line, wherever the two agree on the values.
@@ -441,285 +384,6 @@ test('the D117 wrap rule targets only the settings summary button', async () =>
assert.doesNotMatch(css, /#viewer-details > div > button/);
});
// #331 D139 (design §8): the shared inspector's "Collections…" footer button and its checklist dialog.
const collectionsDialog = await import('../../web/js/workspace/inspector-collections.mjs').catch(() => null);
const dialogModule = () => { assert.ok(collectionsDialog, 'inspector-collections.mjs is not implemented'); return collectionsDialog; };
const COLLECTION_SCOPE = 'a'.repeat(64);
const FILE = {root_index:2, root_scope:COLLECTION_SCOPE, path:'a.png'};
function collectionsStore({member = [3], membership, add, remove} = {}) {
const calls = [];
const store = {
calls,
collections:[{id:3, name:'LoRa testing', count:3, missing:0}, {id:5, name:'Portraits', count:1, missing:0}],
membership: async target => { calls.push(['membership', target]); return membership ? membership() : [...member]; },
add: async (where, items) => { calls.push(['add', where, items]); if (add) return add(where, items);
return {collection:{id:where.collectionId ?? 9, name:where.name ?? 'x'}, created:!!where.name, added:1, already:0, notFound:0}; },
remove: async (id, items) => { calls.push(['remove', id, items]); if (remove) return remove(id, items);
return {collection:{id, name:'x'}, removed:1}; },
settled: async () => ({}),
};
return store;
}
function footerButtons(f) {
return f.walk(f.container, n => n.tagName === 'button').map(n => n.textContent.trim());
}
test('inspector shows Collections… only for a rooted file with a store', async () => {
for (const variant of ['panel', 'full']) {
const f = mountedDom(), scope = createScope();
try {
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, collections:collectionsStore()});
view.setState({loading:false, error:false, record:{...record(), historyTarget:'#/library/1'}});
const labels = footerButtons(f);
assert.ok(labels.includes('Collections…'), `${variant}: the button is offered`);
// After Prompt history, as the last footer action.
assert.equal(labels.at(-1), 'Collections…');
assert.equal(labels.at(-2), 'Prompt history');
const button = f.walk(f.container, n => n.tagName === 'button' && n.textContent.trim() === 'Collections…')[0];
assert.equal(button.children[0].tagName, 'svg', 'with the folder icon');
view.dispose();
} finally { scope.dispose(); f.restore(); }
}
// No store (Prompt library galleries, Inspect a file) or a file that is not a listed one: no button.
const local = await localRecord(localPng('Fox Render.png'));
for (const [shown, store] of [[record(), null], [local, collectionsStore()],
[{...record(), identity:{...record().identity, root_scope:null}}, collectionsStore()]]) {
const f = mountedDom(), scope = createScope();
try {
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant:'full', collections:store});
view.setState({loading:false, error:false, record:shown});
assert.ok(!footerButtons(f).includes('Collections…'));
view.dispose();
} finally { scope.dispose(); f.restore(); }
}
});
/** Open the Collections dialog over FILE; returns its nodes by role and helpers. */
function openCollections(store, extra = {}) {
const {openCollectionsDialog} = dialogModule();
const f = mountedDom(), scope = createScope(), toasts = [], changes = [];
let dialog = null, closed = 0;
openCollectionsDialog({scope, collections:store, target:FILE, trigger:null, notify:text => toasts.push(text),
onChanged:change => changes.push(change), openDialog:args => { dialog = args; f.container.append(...args.nodes); },
// As the shell does: closing runs the dialog's onClose once.
closeDialog:() => { closed++; dialog?.onClose?.(); }, ...extra});
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
const find = predicate => f.walk(f.container, predicate);
const boxes = () => find(n => n.tagName === 'input' && n.type === 'checkbox');
const button = text => find(n => n.tagName === 'button' && n.textContent === text)[0];
const text = () => find(n => n.tagName === 'p').map(n => n.textContent).filter(Boolean);
return {f, scope, toasts, changes, get dialog() { return dialog; }, get closed() { return closed; }, settle, find, boxes, button, text,
done() { scope.dispose(); f.restore(); }};
}
test('the Collections dialog loads the membership and lists every collection', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
assert.equal(view.dialog.title, 'Collections');
assert.ok(view.text().includes('Loading collections…'));
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
await view.settle();
assert.deepEqual(store.calls, [['membership', FILE]]);
const labels = view.find(n => n.tagName === 'label' && n.className === 'tag-suggestion');
assert.deepEqual(labels.map(label => label.textContent), ['LoRa testing', 'Portraits']);
assert.deepEqual(view.boxes().map(box => box.checked), [true, false]);
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
assert.equal(field.maxLength, 100);
assert.equal(view.find(n => n.tagName === 'label' && n.htmlFor === field.id)[0].textContent, 'New collection');
assert.deepEqual(view.find(n => n.className === 'dialog-actions')[0].children.map(n => n.textContent), ['Cancel', 'Save']);
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
} finally { view.done(); }
});
test('without collections the dialog says to name one', async () => {
const store = collectionsStore({member:[]});
store.collections = [];
const view = openCollections(store);
try {
await view.settle();
assert.ok(view.text().includes('No collections yet. Name one below.'));
assert.equal(view.boxes().length, 0);
} finally { view.done(); }
});
test('a failed membership read offers Retry', async () => {
let failing = true;
const store = collectionsStore({membership:() => { if (failing) throw new TypeError('offline'); return [5]; }});
const view = openCollections(store);
try {
await view.settle();
assert.ok(view.text().includes('Collections could not be loaded.'));
assert.equal(view.button('Retry').hidden, false);
assert.equal(view.boxes().length, 0);
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
failing = false;
view.button('Retry').fire('click');
await view.settle();
assert.deepEqual(view.boxes().map(box => box.checked), [false, true]);
assert.equal(view.button('Retry').hidden, true);
} finally { view.done(); }
});
test('Save sends the new name, each added and each removed collection, in that order', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
await view.settle();
const [lora, portraits] = view.boxes();
lora.checked = false; portraits.checked = true;
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' Grids ';
view.button('Save').fire('click');
await view.settle(); await view.settle();
assert.deepEqual(store.calls.slice(1), [
['add', {name:'Grids'}, [FILE]],
['add', {collectionId:5}, [FILE]],
['remove', 3, [FILE]],
]);
assert.equal(view.closed, 1);
assert.deepEqual(view.toasts, ['Collections updated.']);
assert.deepEqual(view.changes, [{added:[9, 5], removed:[3]}]);
} finally { view.done(); }
});
test('Save without a change sends nothing and closes', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
await view.settle();
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' ';
view.button('Save').fire('click');
await view.settle();
assert.deepEqual(store.calls.map(call => call[0]), ['membership']);
assert.equal(view.closed, 1);
assert.deepEqual(view.toasts, []);
assert.deepEqual(view.changes, []);
} finally { view.done(); }
});
test('a failed Save keeps the dialog, says so and shows the current membership', async () => {
let reads = 0;
const store = collectionsStore({membership:() => (reads++ ? [5] : [3]),
remove:() => { throw Object.assign(new Error('x'), {status:500}); }});
const view = openCollections(store);
try {
await view.settle();
const [lora, portraits] = view.boxes();
lora.checked = false; portraits.checked = true;
view.button('Save').fire('click');
// While it saves, × and Escape keep the dialog and Save is busy.
assert.equal(view.dialog.canClose(), false);
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
for (let index = 0; index < 4; index++) await view.settle();
assert.equal(view.closed, 0);
assert.deepEqual(view.toasts, []);
const alert = view.find(n => n.getAttribute?.('role') === 'alert')[0];
assert.equal(alert.textContent, 'The collections could not be updated. Try again.');
// The add went through; the list shows what the server holds now. Review M2: the page hears about it only once
// the dialog closes, so a listing refresh cannot dispose the dialog before its failure is read.
assert.deepEqual(view.changes, []);
assert.deepEqual(view.boxes().map(box => box.checked), [false, true]);
assert.equal(view.dialog.canClose(), true);
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
view.button('Cancel').fire('click');
assert.deepEqual(view.changes, [{added:[5], removed:[]}]);
} finally { view.done(); }
});
test('a name the store cannot take is refused on the field, keeping the edits (review L5)', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
await view.settle();
const [lora, portraits] = view.boxes();
lora.checked = false; portraits.checked = true;
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
const validity = [];
field.setCustomValidity = text => validity.push(text);
field.reportValidity = () => validity.push('reported');
field.value = 'bad\u0001name';
view.button('Save').fire('click');
for (let index = 0; index < 3; index++) await view.settle();
assert.deepEqual(store.calls.map(call => call[0]), ['membership'], 'nothing sent and no re-read');
assert.deepEqual(validity, ['The collections could not be updated. Try again.', 'reported']);
assert.deepEqual(view.boxes().map(box => box.checked), [false, true], 'the checkbox edits stay');
assert.equal(view.closed, 0);
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
// Editing the field clears its message.
field.fire('input');
assert.equal(validity.at(-1), '');
} finally { view.done(); }
});
test('after a failed Save, Save waits for the membership re-read (review M1)', async () => {
let release;
const store = collectionsStore({remove:() => { throw new Error('x'); }});
store.settled = () => new Promise(resolve => { release = resolve; });
const view = openCollections(store);
try {
await view.settle();
view.boxes()[0].checked = false;
view.button('Save').fire('click');
for (let index = 0; index < 3; index++) await view.settle();
assert.equal(typeof release, 'function', 'the store is settling');
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
assert.ok(view.text().includes('Loading collections…'));
const sent = store.calls.length;
view.button('Save').fire('click');
await view.settle();
assert.equal(store.calls.length, sent, 'no stale plan is sent');
release({});
for (let index = 0; index < 3; index++) await view.settle();
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
} finally { view.done(); }
});
test('a typed name of an existing collection is that collection checkbox (review L4)', async () => {
// "portraits" names Portraits (not a member): one add by id, no name request.
let store = collectionsStore();
let view = openCollections(store);
try {
await view.settle();
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' PORTRAITS ';
view.button('Save').fire('click');
for (let index = 0; index < 3; index++) await view.settle();
assert.deepEqual(store.calls.slice(1), [['add', {collectionId:5}, [FILE]]]);
} finally { view.done(); }
// Unchecking LoRa testing while typing its name keeps it: nothing to send.
store = collectionsStore();
view = openCollections(store);
try {
await view.settle();
view.boxes()[0].checked = false;
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = 'lora testing';
view.button('Save').fire('click');
await view.settle();
assert.deepEqual(store.calls.map(call => call[0]), ['membership']);
assert.equal(view.closed, 1);
} finally { view.done(); }
});
test('focus starts on the field, moves to the first checkbox once listed, and falls back on close (review L6)', async () => {
const trigger = {isConnected:false, focus() { assert.fail('a detached trigger is not focused'); }};
const fallback = {focused:false, focus() { this.focused = true; }};
const view = openCollections(collectionsStore(), {trigger, focusFallback:() => fallback});
try {
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
assert.equal(view.dialog.focusTarget, field);
await view.settle();
assert.equal(view.boxes()[0].focused, true);
view.button('Cancel').fire('click');
assert.equal(fallback.focused, true);
} finally { view.done(); }
const empty = collectionsStore({member:[]});
empty.collections = [];
const none = openCollections(empty);
try {
await none.settle();
assert.equal(none.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].focused, true);
} finally { none.done(); }
});
test('category chip names its origin visibly and shows an own category for any link status (#332 D146)', async () => {
const base = await localRecord(localPng('a.png'));
const chipTexts = record => {
+3 -66
View File
@@ -253,16 +253,15 @@ test('an unreadable or malformed models read falls back to the documented defaul
test('observe failures and malformed answers end in observe-error and retry observes again', async () => {
const { createImageTaggingController } = await load();
let answer = () => fail(500, 'observe-failed');
let answer = () => fail(409, 'source-changed', 'The source changed. Refresh the image collection before tagging this file.');
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answer() }));
const c = createImageTaggingController({ api: h.api, target: TARGET });
await c.open();
assert.equal(c.state.phase, 'observe-error');
assert.equal(c.state.statusText, 'The image could not be opened for tagging.');
assert.equal(c.state.statusText, 'The source changed. Refresh the image collection before tagging this file.');
assert.equal(c.state.canSave, false);
assert.equal(c.state.canGenerate, false);
assert.equal(c.state.canRetry, true);
assert.equal(c.state.blocked, true, 'a failed capture blocks Generate and Save with its reason');
answer = () => response(200, observed({ tag_revision: -1 }));
await c.retry();
assert.equal(c.state.phase, 'observe-error');
@@ -275,71 +274,9 @@ test('observe failures and malformed answers end in observe-error and retry obse
await c.retry();
await settle();
assert.equal(c.state.phase, 'ready');
assert.equal(c.state.blocked, false);
assert.equal(h.of('POST', OBSERVE).length, 4);
});
test('#430: a changed source blocks the dialog until it is retargeted, keeping the draft', async () => {
const { createImageTaggingController } = await load();
const SOURCE = 'The source changed. Refresh the image collection before tagging this file.';
const fresh = { root_index: 1, root_scope: 'e'.repeat(64), path: TARGET.path };
const h = harness(defaultRoutes({
[`POST ${OBSERVE}`]: call => (call.body.target.root_scope === SCOPE ? fail(409, 'source-changed', SOURCE) : response(200, observed())),
}));
const c = createImageTaggingController({ api: h.api, target: TARGET });
await c.open();
assert.equal(c.state.phase, 'source-changed');
assert.equal(c.state.statusText, SOURCE);
assert.deepEqual([c.state.canSave, c.state.canGenerate, c.state.canRetry, c.state.blocked], [false, false, false, true]);
assert.equal(await c.retry(), false, 'the same stale target is never observed again');
assert.equal(h.of('POST', OBSERVE).length, 1);
assert.throws(() => c.retarget({ ...fresh, root_scope: 'bad' }), TypeError);
await c.retarget(fresh);
await settle();
assert.equal(c.state.phase, 'ready');
assert.deepEqual(h.of('POST', OBSERVE).at(-1).body.target, fresh);
assert.deepEqual({ ...c.target }, fresh);
// Generate and Save now carry the new target.
c.setManual('kept draft');
await c.generate();
assert.deepEqual(h.of('POST', SUGGEST)[0].body.target, fresh);
});
test('#430: a source change answered to Generate or Save also blocks; retarget keeps the typed draft', async () => {
for (const route of [SUGGEST, TAGS]) {
const method = route === TAGS ? 'PUT' : 'POST';
let stale = true;
const { c, h } = await opened({
[`${method} ${route}`]: call => (stale ? fail(409, 'source-changed', 'server says source') :
route === TAGS ? response(200, receipt(call.body.tags)) : response(200, suggested())),
});
c.setManual('kept draft');
if (route === TAGS) await c.save(); else await c.generate();
assert.equal(c.state.phase, 'source-changed');
assert.equal(c.state.statusText, 'server says source');
stale = false;
await c.retarget({ root_index: 2, root_scope: 'f'.repeat(64), path: TARGET.path });
await settle();
assert.equal(c.state.phase, 'ready');
assert.equal(c.state.manual, 'kept draft');
assert.equal(h.of('DELETE', CAPTURE).length, 1, 'the old capture is released');
}
});
test('#430: retarget waits for nothing: it refuses while a save is in flight and after close', async () => {
const gate = deferred();
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => gate.promise });
const saving = c.save();
await flush();
assert.equal(c.state.phase, 'saving');
assert.equal(await c.retarget({ ...TARGET, root_index: 3 }), false);
gate.resolve(response(200, receipt(['Minimal'])));
await saving;
c.cancel();
assert.equal(await c.retarget({ ...TARGET, root_index: 3 }), false);
assert.equal(h.of('POST', OBSERVE).length, 1);
});
test('model change clears suggestions count and status but keeps manual', async () => {
const { c } = await opened();
c.setManual('Minimal, dune');
@@ -610,7 +547,7 @@ test('write errors keep the draft and allow another save', async () => {
});
test('storage-changed and file-changed disable save until reopen', async () => {
for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'source-changed'], ['capture-expired', 'capture-expired']]) {
for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'file-changed'], ['capture-expired', 'capture-expired']]) {
const message = `server says ${code}`;
let answer = () => fail(409, code, message);
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => answer() });
@@ -59,9 +59,3 @@ test('the toolbar list merges prompt and image categories once each', async () =
assert.deepEqual(mergeCategories(['A', '', ' ', 7], null), ['A']);
assert.deepEqual(mergeCategories(undefined, ['landscapes', 'Landscapes']), ['landscapes', 'Landscapes']);
});
test('the outcome names what was set: images by default, prompts for the Library bar (#437)', async () => {
const {setOutcome} = await source;
assert.equal(setOutcome({updated:1, unchanged:0, not_found:0}, 'A', 'prompt'), 'Category “A” set on 1 prompt.');
assert.equal(setOutcome({updated:2, unchanged:0, not_found:0}, 'A', 'prompt'), 'Category “A” set on 2 prompts.');
});
-285
View File
@@ -1,285 +0,0 @@
// The Images selection bar's collection actions (#331 D138, design §7): Add to collection after Set category, Remove
// from collection beside it while a collection filter is active, the 500 cap and every toast of §7.4.
import test from 'node:test';
import assert from 'node:assert/strict';
// Guarded: before the module exists every test fails on an assertion, not on the import.
const loaded = await import('../../web/js/workspace/images-collections.mjs').catch(() => null);
const module = () => { assert.ok(loaded, 'images-collections.mjs is not implemented'); return loaded; };
const { mountSelectionBar } = await import('../../web/js/workspace/images-selection.mjs');
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
const strings = await import('../../web/js/workspace/collections.mjs');
const SCOPE = 'b'.repeat(64);
const image = (path, extra = {}) => ({ root_index: 0, root_scope: SCOPE, path, filename: path.split('/').pop(), ...extra });
const target = path => ({ root_index: 0, root_scope: SCOPE, path });
const images = count => Array.from({ length: count }, (_, index) => image(`set/${index}.png`));
const keyOf = item => `${item.root_index}:${item.path}`;
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
const coded = (code, status = 400) => Object.assign(new Error(code), { name: 'ApiError', code, status });
/** A minimal DOM for the selection bar: elements with order, attributes, listeners, focus and a toast. */
function fakeDOM() {
const previous = globalThis.document;
let active = null;
class Element extends EventTarget {
constructor(tag) {
super();
this.tagName = tag.toUpperCase();
this.children = [];
this.parentNode = null;
this.attributes = {};
this.dataset = {};
this.className = '';
this.id = '';
this._text = '';
}
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
set textContent(text) { this.children = []; this._text = String(text); }
get nextElementSibling() {
const siblings = this.parentNode?.children ?? [];
return siblings[siblings.indexOf(this) + 1] ?? null;
}
append(...nodes) {
for (const node of nodes) { node.remove(); node.parentNode = this; this.children.push(node); }
}
after(node) {
node.remove();
node.parentNode = this.parentNode;
this.parentNode.children.splice(this.parentNode.children.indexOf(this) + 1, 0, node);
}
remove() {
if (!this.parentNode) return;
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
this.parentNode = null;
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
removeAttribute(name) { delete this.attributes[name]; }
querySelector() { return this.children.find(child => child.tagName === 'BUTTON' && child.getAttribute('aria-disabled') !== 'true') ?? null; }
querySelectorAll() { return []; }
focus() { active = this; }
click() { this.dispatchEvent(new Event('click')); }
}
const toast = new Element('div');
const body = new Element('body');
globalThis.document = {
body,
get activeElement() { return active ?? body; },
createElement: tag => new Element(tag),
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
getElementById: id => (id === 'workspace-toast' ? toast : null),
};
return { Element, body, toast, restore() { globalThis.document = previous; } };
}
/** The bar over `items` with `checked` keys; `options` adds the collection props. */
function mountBar(dom, items, checkedKeys, options = {}) {
const scope = createScope();
const grid = new dom.Element('div');
const focusFallback = new dom.Element('h1');
const bar = mountSelectionBar({ scope, api: {}, grid, getItems: () => items, focusFallback, onCategorySet() {},
activity: options.activity ?? null, onTagged() {}, getFilter: () => ({}),
getListing: () => ({ total: options.total ?? items.length, hasMore: false, positiveCount: null }),
checked: Object.freeze(new Set(checkedKeys)), ...options.props });
return { scope, bar, labels: () => bar.element.children[0]?.children.map(child => child.textContent) ?? [],
button: text => bar.element.children[0].children.find(child => child.textContent === text) };
}
function store(overrides = {}) {
const calls = [];
return {
calls,
collections: [{ id: 3, name: 'Experiments', count: 8, missing: 0 }, { id: 7, name: 'Landscapes', count: 12, missing: 1 }],
add: async (where, items) => { calls.push(['add', where, items]); return overrides.add?.(where, items); },
remove: async (id, items) => { calls.push(['remove', id, items]); return overrides.remove?.(id, items); },
};
}
test('selection bar lists Add to collection after Set category', () => {
const dom = fakeDOM();
try {
const items = images(2);
const collections = store();
const plain = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections } });
assert.deepEqual(plain.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Export metadata', 'Clear']);
plain.scope.dispose();
// §7.1: Remove from collection beside Add, only while a collection filter is active.
const filtered = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections, collection: '7' } });
assert.deepEqual(filtered.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Remove from collection', 'Export metadata', 'Clear']);
filtered.scope.dispose();
// Without the client store (a page that has none) neither shows.
const none = mountBar(dom, items, [keyOf(items[0])]);
assert.deepEqual(none.labels(), ['1 selected', 'Set category', 'Export metadata', 'Clear']);
none.scope.dispose();
} finally { dom.restore(); }
});
test('more than 500 checked toasts and opens nothing', async () => {
const dom = fakeDOM();
try {
const items = images(501);
const collections = store();
const view = mountBar(dom, items, items.map(keyOf), { props: { collections, collection: '7' } });
view.button('Add to collection').click();
assert.equal(dom.toast.textContent, 'Select at most 500 images.');
assert.equal(dom.body.children.length, 0, 'no dialog opened');
dom.toast.textContent = '';
view.button('Remove from collection').click();
await flush();
assert.equal(dom.toast.textContent, 'Select at most 500 images.');
assert.deepEqual(collections.calls, []);
view.scope.dispose();
} finally { dom.restore(); }
});
test('Remove from collection removes the checked files and reloads the listing', async () => {
const dom = fakeDOM();
try {
const items = images(3);
let changed = 0;
const collections = store({ remove: () => ({ collection: { id: 7, name: 'Landscapes' }, removed: 2 }) });
const view = mountBar(dom, items, [keyOf(items[0]), keyOf(items[2])], { props: { collections, collection: '7',
onCollectionsChanged: () => { changed++; } } });
const remove = view.button('Remove from collection');
remove.click();
// A second press while the first is in flight is ignored.
assert.equal(remove.getAttribute('aria-disabled'), 'true');
remove.click();
await flush();
assert.deepEqual(collections.calls, [['remove', 7, [target('set/0.png'), target('set/2.png')]]]);
assert.equal(dom.toast.textContent, 'Removed 2 images from “Landscapes”.');
assert.equal(changed, 1);
assert.equal(remove.getAttribute('aria-disabled'), null);
view.scope.dispose();
} finally { dom.restore(); }
});
test('the collection-target rule keeps the all-matching selection to its loaded images', () => {
const { collectionTargets } = module();
assert.deepEqual(collectionTargets(images(2)), { targets: [target('set/0.png'), target('set/1.png')] });
assert.deepEqual(collectionTargets(images(501)), { refusal: strings.TOO_MANY_TOAST });
// All N matching: only when every match is loaded and checked, and at most 500.
assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 2 }), { targets: [target('set/0.png'), target('set/1.png')] });
assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 3 }), { refusal: strings.TOO_MANY_TOAST });
assert.deepEqual(collectionTargets(images(501), { allMatching: true, total: 501 }), { refusal: strings.TOO_MANY_TOAST });
// A file without a listing identity cannot be named; none left is "could not be found".
assert.deepEqual(collectionTargets([{ ...image('x.png'), root_scope: 'nope' }]), { refusal: strings.FILES_NOT_FOUND_TOAST });
});
const pageScope = () => {
const controller = new AbortController();
return { signal: controller.signal, cleanup() {}, abort: () => controller.abort() };
};
test('Add to collection opens the concept dialog with the existing names as suggestions', () => {
const { openAddToCollection } = module();
let dialog = null;
const collections = store();
openAddToCollection({ scope: pageScope(), collections, images: images(1), trigger: 'trigger', notify: assert.fail,
openInput: args => { dialog = args; } });
assert.equal(dialog.title, 'Add selected images to collection');
assert.equal(dialog.label, 'Collection name');
assert.equal(dialog.action, 'Add');
assert.equal(dialog.maxLength, 100);
assert.equal(dialog.trigger, 'trigger');
assert.deepEqual(dialog.suggestions, ['Experiments', 'Landscapes']);
assert.equal(dialog.value ?? '', '');
});
/** Apply `name` in the Add dialog over `count` images; resolves with the toasts and the store's calls. */
async function add(name, answer, { count = 1, extra = {} } = {}) {
const { openAddToCollection } = module();
const toasts = [];
let dialog = null, sourceChanged = 0;
const collections = store({ add: answer });
openAddToCollection({ scope: pageScope(), collections, images: images(count), notify: text => toasts.push(text),
openInput: args => { dialog = args; }, onSourceChanged: () => { sourceChanged++; }, ...extra });
await dialog.onApply(name);
return { toasts, calls: collections.calls, sourceChanged };
}
const answer = (added, already, notFound, created = false) => () => ({ collection: { id: 9, name: 'LoRa testing' }, created,
added, already, notFound });
test('Add sends the normalized name and the checked files', async () => {
const { toasts, calls } = await add(' LoRa testing ', answer(3, 0, 0, true), { count: 3 });
assert.deepEqual(calls, [['add', { name: 'LoRa testing' }, [target('set/0.png'), target('set/1.png'), target('set/2.png')]]]);
assert.deepEqual(toasts, ['Added 3 images to “LoRa testing”.']);
});
test('Add toasts every outcome of §7.4', async () => {
const cases = [
[answer(1, 0, 0), 'Added 1 image to “LoRa testing”.'],
[answer(1, 1, 0), 'Added 1 image to “LoRa testing”. 1 was already in it.'],
[answer(2, 3, 0), 'Added 2 images to “LoRa testing”. 3 were already in it.'],
[answer(1, 0, 1), 'Added 1 image to “LoRa testing”. 1 could not be found.'],
[answer(1, 2, 4), 'Added 1 image to “LoRa testing”. 2 were already in it. 4 could not be found.'],
[answer(0, 2, 0), 'All selected images were already in “LoRa testing”.'],
[answer(0, 1, 1), 'Added 0 images to “LoRa testing”. 1 was already in it. 1 could not be found.'],
[() => { throw coded('files-not-found', 404); }, 'None of the selected images could be found. Refresh the images and try again.'],
[() => { throw coded('collection-not-found', 404); }, 'That collection no longer exists.'],
[() => { throw new TypeError('offline'); }, 'The images could not be added. Try again.'],
];
for (const [reply, text] of cases) assert.deepEqual((await add('LoRa testing', reply)).toasts, [text], text);
});
test('a stale source on Add says so and reloads the listing', async () => {
const { toasts, sourceChanged } = await add('Grids', () => { throw coded('source-changed', 409); });
assert.deepEqual(toasts, ['The sources changed. Refresh the images and try again.']);
assert.equal(sourceChanged, 1);
});
test('Add with a name the store cannot take sends nothing and says it failed', async () => {
const { toasts, calls } = await add('bad\u0001name', answer(1, 0, 0));
assert.deepEqual(calls, []);
assert.deepEqual(toasts, ['The images could not be added. Try again.']);
});
test('Add after the page left shows no toast', async () => {
const { openAddToCollection } = module();
const scope = pageScope();
const toasts = [];
let dialog = null;
openAddToCollection({ scope, collections: store({ add: answer(1, 0, 0) }), images: images(1), notify: text => toasts.push(text),
openInput: args => { dialog = args; } });
scope.abort();
await dialog.onApply('Grids');
assert.deepEqual(toasts, []);
});
test('Add refuses an over-cap or all-matching selection before any dialog', () => {
const { openAddToCollection } = module();
const toasts = [];
const open = extra => openAddToCollection({ scope: pageScope(), collections: store(), notify: text => toasts.push(text),
openInput: () => assert.fail('no dialog opens'), ...extra });
open({ images: images(501) });
open({ images: images(2), allMatching: true, total: 40 });
assert.deepEqual(toasts, [strings.TOO_MANY_TOAST, strings.TOO_MANY_TOAST]);
});
/** Remove `count` images from collection 7 with `reply`; resolves with the toasts and callbacks. */
async function remove(reply, count = 1) {
const { removeFromCollection } = module();
const toasts = [];
let changed = 0, sourceChanged = 0;
const collections = store({ remove: reply });
await removeFromCollection({ scope: pageScope(), collections, collectionId: 7, images: images(count),
notify: text => toasts.push(text), onChanged: () => { changed++; }, onSourceChanged: () => { sourceChanged++; } });
return { toasts, changed, sourceChanged, calls: collections.calls };
}
test('Remove toasts every outcome of §7.4', async () => {
assert.deepEqual(await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 1 })),
{ toasts: ['Removed 1 image from “Landscapes”.'], changed: 1, sourceChanged: 0, calls: [['remove', 7, [target('set/0.png')]]] });
assert.deepEqual((await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 3 }), 3)).toasts,
['Removed 3 images from “Landscapes”.']);
// A failure keeps the listing; a deleted collection reloads it (the page then says the collection is gone).
const failed = await remove(() => { throw new TypeError('offline'); });
assert.deepEqual([failed.toasts, failed.changed], [['The images could not be removed. Try again.'], 0]);
const gone = await remove(() => { throw coded('collection-not-found', 404); });
assert.deepEqual([gone.toasts, gone.changed], [['That collection no longer exists.'], 1]);
const stale = await remove(() => { throw coded('source-changed', 409); });
assert.deepEqual([stale.toasts, stale.changed, stale.sourceChanged], [['The sources changed. Refresh the images and try again.'], 0, 1]);
});
@@ -1,172 +0,0 @@
// Run with: node --test tests/js/workspace-images-source-recovery.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const load = () => import('../../web/js/workspace/images-source-recovery.mjs');
const OLD = Object.freeze({ root_index: 0, root_scope: 'a'.repeat(64), path: 'sub/a.png', filename: 'a.png' });
const FRESH = Object.freeze({ root_index: 1, root_scope: 'b'.repeat(64), path: 'sub/a.png', filename: 'a.png', url: '/u' });
function fakeApi(answer) {
const calls = [];
return { calls, post: async (url, body, options) => { calls.push({ url, body, options }); return answer(body); } };
}
const flush = () => new Promise(resolve => setImmediate(resolve));
test('rescopeImage posts only the target and returns the fresh descriptor', async () => {
const { rescopeImage, RESCOPE_URL } = await load();
const api = fakeApi(() => ({ success: true, image: FRESH }));
const signal = new AbortController().signal;
const fresh = await rescopeImage(api, OLD, { signal });
assert.deepEqual(fresh, FRESH);
assert.ok(Object.isFrozen(fresh));
assert.deepEqual(api.calls, [{ url: RESCOPE_URL, body: { target: { root_index: 0, root_scope: OLD.root_scope, path: 'sub/a.png' } }, options: { signal } }]);
});
test('rescopeImage answers null for a refusal, a malformed answer, another path or a bad target', async () => {
const { rescopeImage } = await load();
const refused = { post: async () => { throw Object.assign(new Error('gone'), { status: 409, code: 'source-changed' }); } };
assert.equal(await rescopeImage(refused, OLD), null);
const lost = { post: async () => { throw new TypeError('Failed to fetch'); } };
await assert.rejects(rescopeImage(lost, OLD), TypeError, 'a lost answer is not final');
for (const answer of [null, { success: false }, { success: true, image: { ...FRESH, root_scope: 'x' } },
{ success: true, image: { ...FRESH, path: 'other.png' } }, { success: true, image: { ...FRESH, root_index: -1 } }]) {
assert.equal(await rescopeImage(fakeApi(() => answer), OLD), null);
}
const api = fakeApi(() => ({ success: true, image: FRESH }));
for (const image of [null, { ...OLD, root_scope: 'short' }, { ...OLD, path: '' }, { ...OLD, root_index: 1.5 }]) {
assert.equal(await rescopeImage(api, image), null);
}
assert.equal(api.calls.length, 0, 'a malformed target is never sent');
});
test('one rescope per held pair: callers share it, a tried pair answers its first result, reset forgets', async () => {
const { createSourceRecovery } = await load();
let answer = { success: true, image: FRESH };
const api = fakeApi(() => answer);
const applied = [];
const recovery = createSourceRecovery({ api, generation: () => 7, apply: async (image, fresh, started) => { applied.push([image.path, fresh.root_index, started]); return fresh; } });
const [first, second] = await Promise.all([recovery.recover(OLD), recovery.recover({ ...OLD })]);
assert.deepEqual([first, second], [FRESH, FRESH]);
assert.equal(api.calls.length, 1);
assert.deepEqual(applied, [['sub/a.png', 1, 7]]);
answer = { success: false };
assert.deepEqual(await recovery.recover(OLD), FRESH, 'the pair is not asked again');
recovery.reset();
assert.equal(await recovery.recover(OLD), null, 'after a reset the pair is asked again');
assert.equal(api.calls.length, 2);
assert.equal(await recovery.recover({ ...OLD, root_scope: 'bad' }), null);
assert.equal(api.calls.length, 2);
});
test('a refused pair never loops, an aborted page applies nothing and a failing apply is null', async () => {
const { createSourceRecovery } = await load();
const api = fakeApi(() => ({ success: true, image: FRESH }));
const page = new AbortController();
let applied = 0;
const recovery = createSourceRecovery({ api, signal: page.signal, apply: async () => { applied += 1; return FRESH; } });
page.abort();
assert.equal(await recovery.recover(OLD), null);
assert.equal(applied, 0);
const failing = createSourceRecovery({ api, apply: async () => { throw new Error('page gone'); } });
assert.equal(await failing.recover(OLD), null);
assert.throws(() => createSourceRecovery({ api }), TypeError);
// An api without post (an embedded gallery's page adapter) finds nothing.
assert.equal(await createSourceRecovery({ api: {}, apply: async () => FRESH }).recover(OLD), null);
});
test('the memo forgets its oldest pairs past its limit', async () => {
const { createSourceRecovery } = await load();
const api = fakeApi(body => ({ success: true, image: { ...FRESH, path: body.target.path } }));
const recovery = createSourceRecovery({ api, apply: async (_image, fresh) => fresh });
for (let index = 0; index < 65; index++) await recovery.recover({ ...OLD, path: `f${index}.png` });
assert.equal(api.calls.length, 65);
await recovery.recover({ ...OLD, path: 'f64.png' });
assert.equal(api.calls.length, 65, 'a recent pair is still remembered');
await recovery.recover({ ...OLD, path: 'f0.png' });
assert.equal(api.calls.length, 66, 'the oldest pair was forgotten');
});
function fakePage({ selected = true, root = 'all', current = true } = {}) {
const log = [];
const page = {
filters: { root, subfolder: root === 'all' ? '' : 'sub' },
current: started => { log.push(['current', started]); return current; },
selected: image => selected && image.path === OLD.path,
inspect: image => log.push(['inspect', image.root_index]),
dropSelection: () => log.push(['drop']),
stream: {
refresh: async () => { log.push(['refresh']); throw new Error('read failed'); },
reset: (filters, options) => log.push(['reset', { ...filters }, options]),
},
updateScope: () => log.push(['scope']),
closeViewer: () => log.push(['close-viewer']),
clearChecks: () => log.push(['clear-checks']),
listsNow: () => true,
rediscover: () => log.push(['rediscover']),
};
return { page, log };
}
test('a renumbered source keeps nothing by its old key: only the file stays selected', async () => {
const { recoveredSource } = await load();
const { page, log } = fakePage({ root: '0' });
assert.equal(await recoveredSource(page)(OLD, FRESH, 3), FRESH);
assert.deepEqual(log, [['current', 3], ['close-viewer'], ['clear-checks'], ['inspect', 1], ['scope'],
['reset', { root: '1', subfolder: 'sub' }, { load: true }], ['rediscover']]);
});
test('another Source filter falls back to All sources; an unselected file drops the old selection', async () => {
const { recoveredSource } = await load();
const { page, log } = fakePage({ root: '2', selected: false });
await recoveredSource(page)(OLD, FRESH, 0);
assert.deepEqual(log.find(entry => entry[0] === 'reset')[1], { root: 'all', subfolder: '' });
assert.ok(log.some(entry => entry[0] === 'drop'));
assert.ok(!log.some(entry => entry[0] === 'inspect'));
const all = fakePage({ root: 'all' });
await recoveredSource(all.page)(OLD, FRESH, 0);
assert.deepEqual(all.log.find(entry => entry[0] === 'reset')[1], { root: 'all', subfolder: '' });
});
test('an unchanged numbering re-reads in place; a page that moved on is left alone', async () => {
const { recoveredSource } = await load();
const same = { ...FRESH, root_index: 0 };
const { page, log } = fakePage();
assert.equal(await recoveredSource(page)(OLD, same, 1), same);
assert.deepEqual(log, [['current', 1], ['inspect', 0], ['refresh']], 'a failed re-read is not an error');
const unselected = fakePage({ selected: false });
await recoveredSource(unselected.page)(OLD, same, 1);
assert.deepEqual(unselected.log, [['current', 1], ['refresh']]);
const moved = fakePage({ current: false });
assert.equal(await recoveredSource(moved.page)(OLD, FRESH, 1), FRESH, 'the dialog may still retarget');
assert.deepEqual(moved.log, [['current', 1]]);
await flush();
});
test('a scope that still names its root is not stale: nothing is applied', async () => {
const { recoveredSource } = await load();
const { page, log } = fakePage();
assert.equal(await recoveredSource(page)(OLD, { ...OLD, url: '/u' }, 1), null);
assert.deepEqual(log, []);
});
test('a lost rescope answer is not remembered: the pair is asked again', async () => {
const { createSourceRecovery } = await load();
let down = true;
const api = { calls: 0, post: async () => { api.calls += 1; if (down) throw Object.assign(new Error('bad gateway'), { status: 502 }); return { success: true, image: FRESH }; } };
const recovery = createSourceRecovery({ api, apply: async (_image, fresh) => fresh });
assert.equal(await recovery.recover(OLD), null);
down = false;
assert.deepEqual(await recovery.recover(OLD), FRESH);
assert.equal(api.calls, 2);
});
test('the same index under another scope (a changed key) re-reads in place and keeps the selection', async () => {
const { recoveredSource } = await load();
const rekeyed = { ...OLD, root_scope: 'c'.repeat(64) };
const { page, log } = fakePage();
page.stream.refresh = async () => { log.push(['refresh']); };
assert.equal(await recoveredSource(page)(OLD, rekeyed, 2), rekeyed);
assert.deepEqual(log, [['current', 2], ['inspect', 0], ['refresh']]);
assert.ok(!log.some(entry => ['reset', 'drop', 'clear-checks', 'close-viewer'].includes(entry[0])));
});
+5 -52
View File
@@ -141,22 +141,6 @@ test('the Favorites page asks the listing for favorites only (#249 D51)', async
for(const favorites of ['1',1,'true'])await assert.rejects(stream.reset({favorites}),/Invalid favorites filter/);
});
test('a collection page asks the listing for that collection only (#331 D137)', async () => {
const {createImageStream}=await source;let query;
const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}});
await stream.reset({collection:'12',q:'fog'});
assert.equal(query.get('collection'),'12');assert.equal(query.get('q'),'fog');assert.equal(query.has('favorites'),false);
await stream.reset({collection:7});
assert.equal(query.get('collection'),'7');
for(const collection of ['',null,undefined]) {
await stream.reset({collection});
assert.equal(query.has('collection'),false);
}
for(const collection of ['0','07','x',-1,1.5,'1234567890',true])await assert.rejects(stream.reset({collection}),/Invalid collection filter/);
// Favorites and a collection are never both a page's filter.
await assert.rejects(stream.reset({collection:'3',favorites:true}),/Invalid collection filter/);
});
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
const {createImageStream}=await source;
const calls=[];
@@ -211,12 +195,11 @@ test('custom collection pages reuse image-stream cancellation, cursors and ident
assert.throws(()=>createImageStream({api:{get:async()=>{}},fetchPage:'invalid'}),/page/i);
});
test('stream sends category, tags and the sort and validates them; the rating sort is gone (D153)', async () => {
test('stream sends category, tags and the rating sort and validates them', async () => {
const {createImageStream}=await source;const calls=[];
const stream=createImageStream({api:{get:async url=>{calls.push(new URL(url,'http://local').searchParams);return page([]);}}});
await stream.reset({q:'cat',category:'Animals',tags:['cats','studio'],sort:'name-desc'});
assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'name-desc');
await assert.rejects(stream.reset({sort:'rating'}),/sort/i);
await stream.reset({q:'cat',category:'Animals',tags:['cats','studio'],sort:'rating'});
assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'rating');
await stream.reset({category:'',tags:[]});
assert.equal(calls[1].has('category'),false);assert.equal(calls[1].has('tags'),false);
for(const filters of [{category:3},{tags:'cats'},{tags:[1]},{tags:['a,b']},{tags:['']}]) await assert.rejects(stream.reset(filters),/filter/i);
@@ -278,7 +261,7 @@ test('the caption date names the newest result day under Newest first only', asy
assert.equal(captionDate([at(2026,9,10),at(2026,9,16,23)],'oldest',now),'');
assert.equal(captionDate([at(2026,9,13)],'newest',now),'Tuesday · October 13');
assert.equal(captionDate([at(2025,2,3)],'newest',now),'Monday · March 3, 2025');
for(const sort of ['name','name-desc']) assert.equal(captionDate([at(2026,9,17)],sort,now),'');
for(const sort of ['name','name-desc','rating']) assert.equal(captionDate([at(2026,9,17)],sort,now),'');
assert.equal(captionDate([],'newest',now),'');
assert.equal(captionDate([{modified_time:'bad'}],'newest',now),'');
});
@@ -526,35 +509,7 @@ test('defaults apply to a new visit but not to the current view or a saved view'
assert.deepEqual(initialImagesView({ saved: { q: '' }, preferences }), { layout: 'grid', density: 'comfortable' });
// Without readable preferences nothing changes.
assert.deepEqual(initialImagesView({}), { layout: 'grid', density: 'comfortable' });
// #451: a preference object without a layout falls back to the default layout, Masonry.
assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'masonry', density: 'comfortable' });
});
test('a default saved after the last visit started reaches the next visit unless the toolbar changed it (#451)', async () => {
const { initialImagesView } = await source;
const preferences = { workspaceLayout: 'masonry', workspaceDensity: 'compact' };
const started = { layout: 'grid', density: 'comfortable' };
// The visit kept the defaults it started from: the newer defaults win.
assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'comfortable', defaults: started }, preferences }),
{ layout: 'masonry', density: 'compact' });
// A toolbar choice is the current view's own, whatever the defaults became; each value on its own.
assert.deepEqual(initialImagesView({ prior: { layout: 'list', density: 'comfortable', defaults: started }, preferences }),
{ layout: 'list', density: 'compact' });
assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'large', defaults: started }, preferences }),
{ layout: 'masonry', density: 'large' });
// Unchanged defaults: the visit returns as it was.
assert.deepEqual(initialImagesView({ prior: { layout: 'masonry', density: 'compact', defaults: { layout: 'masonry', density: 'compact' } }, preferences }),
{ layout: 'masonry', density: 'compact' });
// A saved view and an embedded gallery never get a prior; without preferences the prior is kept.
assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'comfortable', defaults: started } }),
{ layout: 'grid', density: 'comfortable' });
});
test('the defaults a visit starts from are the fresh-visit defaults (#451)', async () => {
const { visitDefaults } = await source;
assert.deepEqual(visitDefaults({ workspaceLayout: 'list', workspaceDensity: 'large' }), { layout: 'list', density: 'large' });
assert.deepEqual(visitDefaults({}), { layout: 'masonry', density: 'comfortable' });
assert.deepEqual(visitDefaults(null), { layout: 'grid', density: 'comfortable' });
assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'grid', density: 'comfortable' });
});
test('listingFilter is the listing query as an object, without paging (#392)', async () => {
@@ -562,7 +517,5 @@ test('listingFilter is the listing query as an object, without paging (#392)', a
assert.deepEqual(listingFilter({q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:[' x ','y'], media:'video', sort:'oldest', favorites:true}),
{q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:'x,y', media:'video', sort:'oldest', favorites:'1'});
assert.deepEqual(listingFilter({root:'all', q:'', tags:[], media:'all'}), {sort:'newest'});
// #331: "Select all N matching" on a collection page acts on that collection's listing.
assert.deepEqual(listingFilter({collection:'4', q:'cat'}), {q:'cat', sort:'newest', collection:'4'});
assert.throws(() => listingFilter({sort:'sideways'}), TypeError);
});
+20 -64
View File
@@ -117,9 +117,8 @@ test('new prompt from Images waits for a fresh options epoch', async () => {
assert.equal(sentBody.expected_epoch, EPOCH);
assert.deepEqual(sentBody, {
expected_epoch: EPOCH, positive_text: 'a portrait', negative_text: '',
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], notes: '' },
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], rating: null, notes: '' },
});
assert.equal(Object.hasOwn(sentBody.parent_metadata, 'rating'), false, 'D153: a new prompt sends no rating');
assert.equal(controller.state.phase, 'complete');
assert.equal(committed.length, 1);
assert.equal(committed[0].kind, 'create');
@@ -231,11 +230,11 @@ test('editor recovery preserves hyphenated ApiError codes', async () => {
const server = harness(library({ write: () => response(409, { success: false, code, error: 'Conflict', library_epoch: EPOCH, ...extra }) }));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
controller.setField('notes', 'kept');
controller.setField('rating', 5);
await controller.submit();
phases.push(controller.state.phase);
if (controller.state.phase !== 'write-error') errors.push(controller.state.error);
assert.equal(controller.state.draft.notes, 'kept', 'draft preserved');
assert.equal(controller.state.draft.rating, 5, 'draft preserved');
assert.equal(controller.state.receipt, null);
controller.dispose();
}
@@ -307,7 +306,7 @@ test('confirmed save refresh retry is read only', async () => {
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let failReads = 1;
const committed = [], reconciled = [];
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'keep\ntext', edit_token: TOKEN2 }) };
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext', edit_token: TOKEN2 }) };
const base = library({ write: () => response(200, receipt) });
const server = harness(call => {
if (call.method === 'GET' && server.writes().length && failReads-- > 0) return Promise.reject(new TypeError('offline'));
@@ -316,6 +315,7 @@ test('confirmed save refresh retry is read only', async () => {
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
onCommitted: commit => committed.push(commit), onReconciled: result => reconciled.push(result) });
await controller.initialize();
controller.setField('rating', null);
controller.setField('notes', 'keep\ntext');
await controller.submit();
assert.equal(controller.state.phase, 'saved-refresh-error');
@@ -338,7 +338,7 @@ test('confirmed save refresh retry is read only', async () => {
test('legacy title fallback is not an edit', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'keep\ntext' }) };
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext' }) };
const server = harness(library({ write: () => response(200, receipt) }));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
@@ -346,6 +346,7 @@ test('legacy title fallback is not an edit', async () => {
assert.equal(controller.state.baseline.title, null, 'stored title stays distinct');
assert.equal(controller.state.draft.tags, 'soft, warm');
assert.equal(controller.state.draft.negative_text, 'blurry', 'the selected pair negative');
controller.setField('rating', null);
controller.setField('notes', 'keep\ntext');
controller.setField('tags', 'warm, soft,');
controller.setField('category', ' Studio ');
@@ -356,7 +357,7 @@ test('legacy title fallback is not an edit', async () => {
const sentBody = write.body;
assert.equal(sentBody.expected_epoch, EPOCH);
assert.equal(sentBody.if_match, TOKEN);
assert.deepEqual(sentBody.metadata, { notes: 'keep\ntext' });
assert.deepEqual(sentBody.metadata, { rating: null, notes: 'keep\ntext' });
const unchanged = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await unchanged.initialize();
@@ -420,12 +421,12 @@ test('metadata conflict reload keeps changed fields for review and never replays
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let owner = positive();
const server = harness(call => library({ owner, write: write => write.body.if_match === TOKEN2
? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, notes: 'mine' } })
? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, rating: 5 } })
: response(409, { success: false, code: 'metadata-conflict', error: 'changed', library_epoch: EPOCH, positive: owner }) })(call));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
controller.setField('notes', 'mine');
owner = positive({ title: 'Renamed elsewhere', category: 'Elsewhere', edit_token: TOKEN2 });
controller.setField('rating', '5');
owner = positive({ title: 'Renamed elsewhere', notes: 'other writer', edit_token: TOKEN2 });
await controller.submit();
assert.equal(controller.state.phase, 'conflict');
await controller.submit();
@@ -433,20 +434,20 @@ test('metadata conflict reload keeps changed fields for review and never replays
await controller.reloadOwner({ keepDraft: true });
assert.equal(controller.state.phase, 'ready');
assert.equal(controller.state.reviewRequired, true);
assert.equal(controller.state.draft.notes, 'mine', 'changed field kept');
assert.equal(controller.state.draft.rating, 5, 'changed field kept');
assert.equal(controller.state.draft.title, 'Renamed elsewhere', 'untouched field follows the current owner');
assert.equal(controller.state.draft.category, 'Elsewhere');
assert.equal(controller.state.draft.notes, 'other writer');
assert.equal(server.writes().length, 1);
await controller.submit();
assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { notes: 'mine' } });
assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { rating: 5 } });
assert.equal(controller.state.phase, 'complete');
controller.dispose();
const discard = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await discard.initialize();
discard.setField('notes', 'dropped');
discard.setField('rating', 1);
await discard.reloadOwner({ keepDraft: false });
assert.equal(discard.state.draft.notes, '');
assert.equal(discard.state.draft.rating, 3);
assert.equal(discard.state.reviewRequired, false);
discard.dispose();
});
@@ -456,12 +457,12 @@ test('metadata unknown outcome retry reloads the owner instead of replaying', as
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let owner = positive();
const server = harness(call => library({ owner, write: () => {
owner = positive({ notes: 'applied', edit_token: TOKEN2 });
owner = positive({ rating: 5, edit_token: TOKEN2 });
return Promise.reject(new TypeError('Failed to fetch'));
} })(call));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
controller.setField('notes', 'applied');
controller.setField('rating', 5);
await controller.submit();
assert.equal(controller.state.phase, 'unknown-outcome');
await controller.retryUnconfirmed();
@@ -569,10 +570,8 @@ test('revision seeds text from the selected pair and sends Uncategorized for a b
await controller.submit();
assert.deepEqual(server.writes()[0].body, {
expected_epoch: EPOCH, positive_text: 'First line\nrevised', negative_text: 'blurry',
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], notes: 'n' },
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], rating: 3, notes: 'n' },
});
// D153: the revision's new positive is unrated; the owner's stored rating (3) is neither copied nor written.
assert.equal(Object.hasOwn(server.writes()[0].body.parent_metadata, 'rating'), false);
controller.dispose();
});
@@ -590,9 +589,7 @@ test('invalid drafts and server failures keep the draft without a receipt', asyn
assert.equal(controller.state.phase, 'ready');
assert.match(controller.state.error.message, /Title/);
assert.throws(() => controller.setField('unknown', 'x'), TypeError);
// D153: the editor has no rating field.
assert.throws(() => controller.setField('rating', 4), /Unknown editor field: rating/);
assert.throws(() => controller.setField('notes', 4), TypeError);
assert.throws(() => controller.setField('rating', 7), TypeError);
controller.setField('title', 'T');
await controller.submit();
assert.equal(controller.state.phase, 'write-error');
@@ -810,44 +807,3 @@ test('a blank category reused against an uncategorized owner is not unapplied me
controller.dispose();
}
});
// D153 (#447): the workspace no longer edits the 1–5 rating, and a save never clears a stored one.
test('editing a prompt rated 4 sends no rating and the saved prompt keeps 4', async () => {
const { createLibraryEditorController, EDITOR_FIELDS } = await source;
assert.deepEqual(EDITOR_FIELDS, ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes']);
let owner = positive({ rating: 4, title: 'Portrait' });
const server = harness(call => library({ owner, write: write => {
owner = positive({ ...owner, ...write.body.metadata, edit_token: TOKEN2 });
return response(200, { success: true, library_epoch: EPOCH, positive: owner });
} })(call));
const reconciled = [];
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
onReconciled: result => reconciled.push(result) });
await controller.initialize();
assert.equal(Object.hasOwn(controller.state.draft, 'rating'), false, 'the draft has no rating');
assert.equal(Object.hasOwn(controller.state.baseline, 'rating'), false, 'the baseline has no rating');
controller.setField('title', 'Portrait, edited');
await controller.submit();
const [write] = server.writes();
assert.deepEqual(write.body.metadata, { title: 'Portrait, edited' });
assert.equal(controller.state.phase, 'complete');
assert.equal(controller.state.receipt.positive.rating, 4);
assert.equal(reconciled[0].detail.positive.rating, 4, 'the re-read prompt still has its rating');
controller.dispose();
});
test('a reused rated positive is not reported as unapplied metadata for its rating', async () => {
const { createLibraryEditorController } = await source;
const reused = recipe({ positive_created: false, pair_created: true, parent_metadata_applied: false,
positive: positive({ id: 9, text: 'a portrait', title: 'Portrait', category: 'Uncategorized', tags: [], rating: 4, notes: '' }) });
const server = harness(created(() => response(200, reused)));
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
await controller.initialize();
controller.setField('title', 'Portrait');
controller.setField('positive_text', 'a portrait');
await controller.submit();
assert.equal(Object.hasOwn(server.writes()[0].body.parent_metadata, 'rating'), false);
assert.equal(controller.state.phase, 'complete');
assert.equal(controller.state.outcome.metadataNotApplied, false);
controller.dispose();
});
+5 -4
View File
@@ -46,8 +46,9 @@ test('used label boundaries in UTC and local time', async () => {
for (const missing of [null, undefined, '', 'not a date', 42]) assert.equal(usedLabel(missing, new Date()), 'Not used yet');
});
// D153 (#447): cards show no rating, so the layout module has no rating label.
test('the layout module offers no rating label', async () => {
const layout = await source;
assert.equal(Object.hasOwn(layout, 'ratingLabel'), false);
test('rating label omits unrated', async () => {
const { ratingLabel } = await source;
assert.deepEqual(ratingLabel(5), { stars: '★★★★★', hidden: 'Rated 5 out of 5' });
assert.deepEqual(ratingLabel(1), { stars: '★', hidden: 'Rated 1 out of 5' });
for (const unrated of [null, undefined, 0, 6, -1, 2.5, '3', true]) assert.deepEqual(ratingLabel(unrated), { stars: '', hidden: '' });
});
@@ -1,96 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
// #437 D148: the Prompt library's selection bar sets one category on the selected prompts, fenced by the storage epoch.
const source = import('../../web/js/workspace/library-selection.mjs');
test('the dialog is the shared simpleInput with the prompt title, and the route and cap are the legacy ones', async () => {
const {PROMPT_CATEGORY_DIALOG, CATEGORY_URL, MAX_CATEGORY_PROMPTS, TOO_MANY_PROMPTS} = await source;
assert.deepEqual({...PROMPT_CATEGORY_DIALOG}, {title:'Categorize selected prompts', label:'Category', action:'Apply'});
assert.equal(CATEGORY_URL, '/prompt_manager/bulk/category');
assert.equal(MAX_CATEGORY_PROMPTS, 1000);
assert.equal(TOO_MANY_PROMPTS, 'Select at most 1,000 prompts.');
});
test('a confirmed write toasts the shared outcome with "prompt", invalidates and reloads', async () => {
const {writeOutcome} = await source;
assert.deepEqual(writeOutcome({success:true, category:'Studio', updated:2, unchanged:1, not_found:0}, null),
{toast:'Category “Studio” set on 3 prompts.', committed:true, reload:true});
assert.deepEqual(writeOutcome({success:true, category:'Studio', updated:1, unchanged:0, not_found:2}, null),
{toast:'Category “Studio” set on 1 prompt. 2 could not be found.', committed:true, reload:true});
});
test('none found, a restored storage, an unconfirmed answer and a refused write each say what happened', async () => {
const {writeOutcome, NONE_FOUND, STORAGE_CHANGED, UNCONFIRMED, CATEGORY_FAILED} = await source;
assert.deepEqual(writeOutcome({success:true, category:'X', updated:0, unchanged:0, not_found:3}, null),
{toast:NONE_FOUND, committed:false, reload:true});
assert.deepEqual(writeOutcome(null, {status:409, code:'storage-changed'}), {toast:STORAGE_CHANGED, committed:false, reload:true});
for (const failure of [new TypeError('Failed to fetch'), {status:502}, {status:503}, {status:504}]) {
assert.deepEqual(writeOutcome(null, failure), {toast:UNCONFIRMED, committed:true, reload:true});
}
assert.deepEqual(writeOutcome(null, null), {toast:UNCONFIRMED, committed:true, reload:true});
for (const status of [400, 500]) {
assert.deepEqual(writeOutcome(null, {status}), {toast:CATEGORY_FAILED, committed:false, reload:false});
}
assert.equal(STORAGE_CHANGED, 'Prompt storage was restored since this page loaded. No category was changed.');
});
test('the field refuses control characters as validate_category does', async () => {
const {categoryProblem, CONTROL_CHARACTERS} = await source;
assert.equal(categoryProblem('Studio portraits'), null);
assert.equal(categoryProblem('tab\there'), CONTROL_CHARACTERS);
assert.equal(categoryProblem('nul\u0000'), CONTROL_CHARACTERS);
assert.equal(categoryProblem('unicode ✓ é'), null);
});
test('Add tags uses the Images dialog words for prompts, the tag routes and the editor limits (#442)', async () => {
const {PROMPT_TAGS_DIALOG, TAGS_URL, TAG_OPTIONS_URL, MAX_PROMPT_TAGS} = await source;
assert.deepEqual({...PROMPT_TAGS_DIALOG}, {title:'Tag selected prompts', label:'Tags, separated by commas', action:'Apply'});
assert.equal(TAGS_URL, '/prompt_manager/bulk/tags');
assert.equal(TAG_OPTIONS_URL, '/prompt_manager/tags');
assert.equal(MAX_PROMPT_TAGS, 20);
});
test('the Add tags toast says what was added, already present, skipped and missing', async () => {
const {tagsOutcome, writeOutcome, NONE_FOUND} = await source;
assert.equal(tagsOutcome({tags:['a', 'b'], updated:2, unchanged:0, over_limit:0, not_found:0}), 'Added “a, b” to 2 prompts.');
assert.equal(tagsOutcome({tags:['a'], updated:1, unchanged:2, over_limit:1, not_found:3}),
'Added “a” to 1 prompt. 2 prompts already had them. 1 prompt would pass 20 tags and was skipped. 3 could not be found.');
assert.equal(tagsOutcome({tags:['a'], updated:0, unchanged:2, over_limit:0, not_found:0}), '2 prompts already had “a”.');
// Every prompt skipped: found, so the skip sentence, never "none found"; nothing committed.
assert.deepEqual(writeOutcome({success:true, tags:['a'], updated:0, unchanged:0, over_limit:2, not_found:0}, null, 'tags'),
{toast:'2 prompts would pass 20 tags and were skipped.', committed:false, reload:true});
assert.deepEqual(writeOutcome({success:true, tags:['a'], updated:0, unchanged:0, over_limit:0, not_found:2}, null, 'tags'),
{toast:NONE_FOUND, committed:false, reload:true});
});
test('Add tags failures name tags, not the category', async () => {
const {writeOutcome, TAGS_STORAGE_CHANGED, TAGS_UNCONFIRMED, TAGS_FAILED} = await source;
assert.equal(writeOutcome(null, {status:409, code:'storage-changed'}, 'tags').toast, TAGS_STORAGE_CHANGED);
assert.equal(writeOutcome(null, {status:503}, 'tags').toast, TAGS_UNCONFIRMED);
assert.equal(writeOutcome(null, {status:400}, 'tags').toast, TAGS_FAILED);
assert.equal(TAGS_STORAGE_CHANGED, 'Prompt storage was restored since this page loaded. No tags were added.');
});
test('the Add tags field refuses what validate_tags refuses', async () => {
const {tagsProblem, TAG_PROBLEMS} = await source;
assert.equal(tagsProblem('red, blue'), null);
assert.equal(tagsProblem(' , ,'), TAG_PROBLEMS.none);
assert.equal(tagsProblem(Array.from({length:21}, (_, i) => `t${i}`).join(',')), TAG_PROBLEMS.many);
assert.equal(tagsProblem(Array.from({length:21}, () => 'same').join(',')), null);
assert.equal(tagsProblem('x'.repeat(51)), TAG_PROBLEMS.long);
assert.equal(tagsProblem('bad\ttag'), TAG_PROBLEMS.control);
});
test('Select all N loaded is offered while some loaded prompts are unchecked (#444 ruling)', async () => {
const {loadedOffer, offersLoaded} = await source;
assert.equal(loadedOffer(3), 'Select all 3 loaded');
assert.equal(loadedOffer(1203), 'Select all 1,203 loaded');
assert.equal(offersLoaded(new Set([1]), [1, 2, 3]), true);
assert.equal(offersLoaded(new Set([1, 2, 3]), [1, 2, 3]), false);
assert.equal(offersLoaded(new Set(), [1, 2]), false);
assert.equal(offersLoaded(new Set([9]), [9]), false);
// More loaded than any action accepts: never offered (Ctrl/Cmd+A still selects them).
assert.equal(offersLoaded(new Set([1]), Array.from({length:1001}, (_, i) => i + 1)), false);
assert.equal(offersLoaded(new Set([1]), Array.from({length:1000}, (_, i) => i + 1)), true);
});
+2 -68
View File
@@ -5,8 +5,8 @@ const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3,
const page=(items,cursor=null)=>({items,total:3,variant_total:6,has_more:cursor!==null,next_cursor:cursor});
test('parent stream builds literal filters and deduplicates stable identities across infinite pages',async()=>{
const {createLibraryStream}=await source;const calls=[];const pending=[page([item(1),item(2)],'next'),page([item(2),item(3)])];const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}});
assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'name'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false);
const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'name');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose();
assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'rating'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false);
const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'rating');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose();
});
test('the shelf caption total of negative variants comes from variant_total and resets with the filters',async()=>{
const {createLibraryStream}=await source;const pending=[page([item(1)],'next'),{...page([item(2)]),variant_total:7},{...page([item(3)]),variant_total:1}];
@@ -104,69 +104,3 @@ test('archived variant reads use their own scope and cursor without changing the
const active=[];const plain=createLibraryDetail({api:{get:async url=>{active.push(url);return {positive:item(1),variants:[]};}},onChange:()=>{}});await plain.load(1);assert.equal(new URL(active[0],'https://x').searchParams.has('archive'),false);
assert.throws(()=>createLibraryDetail({api:{},onChange:()=>{},archive:'parents'}));
});
test('tag mode any and a folder are sent, echoed and validated (#444)',async()=>{
const {createLibraryStream,LIBRARY_TAG_MODES}=await source;assert.deepEqual(LIBRARY_TAG_MODES,['all','any','untagged']);
const calls=[];const pending=[{...page([item(1)]),tag_mode:'any',folder:'2026/Aug'},{...page([item(2)]),tag_mode:'all',folder:''},page([item(3)]),{...page([item(4)]),tag_mode:'all'}];
const s=createLibraryStream({api:{get:async(url)=>{calls.push(new URL(url,'https://x').searchParams);return pending.shift();}}});
assert.equal(await s.reset({tags:['a','b'],tagMode:'any',folder:'2026/Aug'}),1);
assert.equal(calls[0].get('tag_mode'),'any');assert.equal(calls[0].get('folder'),'2026/Aug');assert.deepEqual(calls[0].getAll('tag'),['a','b']);
// An older server that ignored them answers without the echo: an error, never the unfiltered shelf.
assert.equal(await s.reset({tags:['a','b'],tagMode:'any',folder:'2026/Aug'}),0);assert.ok(s.state.error instanceof TypeError);
// The defaults send neither, so they need no echo.
assert.equal(await s.reset({tagMode:'all',folder:''}),1);assert.equal(calls[2].has('tag_mode'),false);assert.equal(calls[2].has('folder'),false);
assert.equal(await s.reset({folder:'x'}),0);assert.ok(s.state.error instanceof TypeError,'a folder without its echo is refused');
await assert.rejects(s.reset({tagMode:'or'}),TypeError);await assert.rejects(s.reset({folder:3}),TypeError);
assert.equal(calls.length,4);s.dispose();
});
test('tag mode untagged is sent, echoed and never combined with tags (#444)',async()=>{
const {createLibraryStream}=await source;const calls=[];const pending=[{...page([item(1)]),tag_mode:'untagged'},page([item(2)]),page([item(3)])];
const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}});
assert.equal(await s.reset({tagMode:'untagged'}),1);assert.equal(new URL(calls[0],'https://x').searchParams.get('tag_mode'),'untagged');
// An older server that ignored tag_mode answers without the echo: an error, never the whole Library.
assert.equal(await s.reset({tagMode:'untagged'}),0);assert.ok(s.state.error instanceof TypeError);assert.deepEqual(s.state.items,[]);
// The default sends no tag_mode, so it needs no echo.
assert.equal(await s.reset({tagMode:'all'}),1);assert.equal(new URL(calls[2],'https://x').searchParams.has('tag_mode'),false);
await assert.rejects(s.reset({tagMode:'untagged',tags:['a']}),TypeError);await assert.rejects(s.reset({tagMode:'or'}),TypeError);
assert.equal(calls.length,3);s.dispose();
});
test('the stream keeps the first page\'s storage epoch and refuses a later page from another one (#437)',async()=>{
const {createLibraryStream}=await source;const A='11111111-1111-4111-8111-111111111111',B='22222222-2222-4222-8222-222222222222';
const pending=[{...page([item(1)],'next'),library_epoch:A},{...page([item(2)],'more'),library_epoch:A},{...page([item(3)]),library_epoch:B}];
const s=createLibraryStream({api:{get:async()=>pending.shift()}});assert.equal(s.state.epoch,null);
await s.reset();assert.equal(s.state.epoch,A);await s.loadMore();assert.equal(s.state.epoch,A);assert.equal(s.state.items.length,2);
await s.loadMore();assert.ok(s.state.error);assert.equal(s.state.items.length,2);assert.equal(s.state.epoch,A);
const restored=createLibraryStream({api:{},restore:{filters:{},state:s.state}});assert.equal(restored.state.epoch,A);restored.dispose();
const fresh=[{...page([item(4)]),library_epoch:B}];const reset=s.reset({text:'x'});assert.equal(s.state.epoch,null);
s.dispose();const next=createLibraryStream({api:{get:async()=>fresh.shift()}});await next.reset();assert.equal(next.state.epoch,B);next.dispose();await reset;
});
test('the shelf no longer sorts by rating (D153)',async()=>{
const {createLibraryStream}=await source;const s=createLibraryStream({api:{get:async()=>page([])}});
await assert.rejects(s.reset({sort:'rating'}),/Invalid library sort/);
assert.throws(()=>createLibraryStream({api:{get:async()=>page([])},restore:{filters:{sort:'rating'},state:{}}}),/Invalid library sort/);
for(const sort of ['newest','oldest','name']) assert.equal(await s.reset({sort}),0);s.dispose();
});
test('Refine › Folder nests each folder under its exact parent, by path segment (#444 live B1)',async()=>{
const {libraryFolderOptions}=await source;assert.equal(typeof libraryFolderOptions,'function');
const I='  ';
// The server's flat list sorts full paths as text, so "-" and "_" land before "/" (live report shapes).
const folders=['CCC','CCC/mabes9sans-dataset','CCC/mabes9sans-dataset-v1','CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN',
'FLUX','FLUX/2025-12-11','flux','flux/images','flux-klein','illustrious','illustrious-base','illustrious/2026-10-26','illustrious/training',
'illustrious/training/a','kiko','kiko-v6','kiko/char','kiko/XXX','QWEN','QWEN/x','Qwen','Qwen/y','Qwen-Training','Video','Video/v','video'].sort();
const options=libraryFolderOptions(folders);
assert.deepEqual(options[0],['','All output folders']);
assert.deepEqual(options.slice(1),[
['CCC','CCC'],['CCC/mabes9sans-dataset',`${I}mabes9sans-dataset`],['CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN',`${I}${I}mabes9sans-dataset_IMG_GEN`],
['CCC/mabes9sans-dataset-v1',`${I}mabes9sans-dataset-v1`],
['FLUX','FLUX'],['FLUX/2025-12-11',`${I}2025-12-11`],['flux','flux'],['flux/images',`${I}images`],['flux-klein','flux-klein'],
['illustrious','illustrious'],['illustrious/2026-10-26',`${I}2026-10-26`],['illustrious/training',`${I}training`],['illustrious/training/a',`${I}${I}a`],
['illustrious-base','illustrious-base'],
['kiko','kiko'],['kiko/char',`${I}char`],['kiko/XXX',`${I}XXX`],['kiko-v6','kiko-v6'],
['QWEN','QWEN'],['QWEN/x',`${I}x`],['Qwen','Qwen'],['Qwen/y',`${I}y`],['Qwen-Training','Qwen-Training'],
['Video','Video'],['Video/v',`${I}v`],['video','video'],
]);
// Every parent precedes its children directly; a chosen folder missing from the list is kept, with its parents.
assert.deepEqual(libraryFolderOptions(['a'],'a/b/c').slice(1),[['a','a'],['a/b',`${I}b`],['a/b/c',`${I}${I}c`]]);
assert.deepEqual(libraryFolderOptions([],''),[['','All output folders']]);
// A POSIX folder whose name holds a backslash is one segment.
assert.deepEqual(libraryFolderOptions(['Metadata\\Klein_4b Wildcard Run']).slice(1),[['Metadata\\Klein_4b Wildcard Run','Metadata\\Klein_4b Wildcard Run']]);
});
+1 -9
View File
@@ -188,7 +188,6 @@ test('an inline Viewer still loading its image is destroyed only after it has vi
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
});
const left0 = dialog => dialog.children[1].children[0];
test('viewer renders the concept shell and toolbar order', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
@@ -200,14 +199,7 @@ test('viewer renders the concept shell and toolbar order', async () => {
assert.equal(dialog.attributes['aria-label'],undefined,'the title names the dialog');
const [header,layout] = dialog.children;
assert.equal(header.className,'dialog-header');
const [heading,mode,close] = header.children, [title,subtitle] = heading.children;
// #450 D156: the view switch before ✕ names the view it switches to (Centered is the stored default).
assert.deepEqual([mode.tagName,mode.id,mode.className,mode.attributes['aria-label'],mode.title],['button','viewer-mode','icon-btn','Full screen view','Full screen view']);
mode.fire('click');
assert.equal(mode.attributes['aria-label'],'Centered view');
assert.ok(f.walk(left0(dialog),n => n.textContent === 'Hide details').length === 1,'full screen adds the details toggle');
mode.fire('click');
assert.equal(mode.attributes['aria-label'],'Full screen view');
const [heading,close] = header.children, [title,subtitle] = heading.children;
assert.deepEqual([heading.tagName,title.tagName,title.id,title.textContent],['div','h2','viewer-title','a.png']);
assert.deepEqual([subtitle.tagName,subtitle.className,subtitle.textContent],['p','muted small','One viewer · image, prompts, generation data, workflow']);
assert.deepEqual([close.tagName,close.id,close.textContent,close.attributes['aria-label']],['button','viewer-close','×','Close image viewer']);
+2 -2
View File
@@ -44,7 +44,7 @@ test('normalizes new workspace keys and keeps legacy keys', () => {
// Invalid workspace values fall back to the defaults, as every legacy key does.
store.text = JSON.stringify({ workspaceLayout: 'mosaic', workspaceDensity: 7, videoMute: 'no' });
const fallback = module.readPreferences(() => store).values;
assert.equal(fallback.workspaceLayout, 'masonry'); // #451: the default layout
assert.equal(fallback.workspaceLayout, 'grid');
assert.equal(fallback.workspaceDensity, 'comfortable');
assert.equal(fallback.videoMute, true);
// The legacy normalizer knows the new keys and still keeps unknown ones.
@@ -94,7 +94,7 @@ test('denied storage reports tab-only', () => {
module.resetTabPreferences();
const read = module.readPreferences(denied);
assert.equal(read.persisted, false);
assert.equal(read.values.workspaceLayout, 'masonry');
assert.equal(read.values.workspaceLayout, 'grid');
const result = module.writePreferences({ workspaceLayout: 'list', videoAutoplay: true }, denied);
assert.deepEqual(result, { persisted: false, conflicts: [] });
// The change is kept for this tab: every reader in it (a new Images visit, the viewer) sees it.
+11 -57
View File
@@ -4,25 +4,16 @@ const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/saved-views.mjs');
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' };
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' };
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] };
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
const { normalizeViewState, SAVED_VIEW_PAGES } = await source;
assert.deepEqual(SAVED_VIEW_PAGES, ['images', 'library']);
assert.deepEqual(normalizeViewState('images', {}), IMAGE_DEFAULTS);
assert.deepEqual(normalizeViewState('library', null), LIBRARY_DEFAULTS);
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true, collection: '' };
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true };
assert.deepEqual(normalizeViewState('images', full), full);
// #331 D140: a collection id is '' or 1 to 9 digits without a leading zero; an old view without the key reads ''.
for (const collection of ['1', '12', '999999999']) {
assert.equal(normalizeViewState('images', { collection }).collection, collection);
}
for (const collection of ['0', 'x', 12, '012', '1234567890', null]) {
assert.equal(normalizeViewState('images', { collection }).collection, '');
}
// Favorites and a collection are never both a view's filter: the collection wins, as the listing's URL does.
assert.deepEqual(normalizeViewState('images', { ...full, collection: '4' }), { ...full, favorites: false, collection: '4' });
// D51: favorites is a boolean; anything else is the whole listing.
for (const favorites of ['true', 1, null]) assert.equal(normalizeViewState('images', { ...full, favorites }).favorites, false);
// Each rejected value falls back on its own; the rest of the view still applies.
@@ -31,8 +22,8 @@ test('a saved state always holds every page key and keeps only values its page a
assert.deepEqual(normalizeViewState('images', { ...full, q: 'x'.repeat(501), folder: 7, tags: Array(21).fill('t') }),
{ ...full, q: '', folder: '', tags: [] });
assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] });
// Library has no source or media, and no Name Z–A; its folder (#444) is the Library's own.
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all', folder: 'a/b' });
// Library has no source, folder or media, and no Name Z–A.
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'] });
assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
// The returned tags are a copy.
const tags = ['one'];
@@ -62,7 +53,7 @@ test('the listing keeps usable views in order and normalizes their state', async
{ id: 1, page: 'images', name: 'Videos', state: { media: 'video', source: '9' } },
] });
assert.deepEqual(views, [
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [], tag_mode: 'all', folder: '' } },
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
{ id: 1, page: 'images', name: 'Videos', state: { ...IMAGE_DEFAULTS, media: 'video', source: '9' } },
]);
assert.equal(parseSavedView(null), null);
@@ -72,20 +63,14 @@ test('the listing keeps usable views in order and normalizes their state', async
test('page filters and saved states convert both ways', async () => {
const { imagesFilters, imagesViewState, libraryFilters, libraryViewState } = await source;
const filters = { q: 'castle', category: 'Portraits', sort: 'name-desc', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' };
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true };
const state = imagesViewState(filters);
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' });
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true });
assert.deepEqual(imagesFilters(state), filters);
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false, collection: '' });
// #331 D140: the open collection round-trips; a missing one is ''.
const inCollection = { ...filters, favorites: false, collection: '7' };
assert.equal(imagesViewState(inCollection).collection, '7');
assert.deepEqual(imagesFilters(imagesViewState(inCollection)), inCollection);
assert.equal(imagesViewState({ ...filters, collection: undefined }).collection, '');
assert.equal(imagesViewState({ ...filters, collection: 7 }).collection, '7');
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false });
assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false);
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tagMode: 'any', folder: '2026' };
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tag_mode: 'any', folder: '2026' });
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] };
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] });
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
});
@@ -215,34 +200,3 @@ test('a state over the API 4 KB cap is refused before anything is sent (#249 M5)
await assert.rejects(store.create('images', 'Big', big), error => error instanceof SavedViewTooLarge && error instanceof RangeError);
assert.deepEqual(api.calls, []);
});
test('a Library view keeps its tag match and folder; an old view reads the defaults (#444)', async () => {
const { normalizeViewState, libraryFilters } = await source;
assert.deepEqual(normalizeViewState('library', { tag_mode: 'any', folder: '2026/Aug' }), { ...LIBRARY_DEFAULTS, tag_mode: 'any', folder: '2026/Aug' });
assert.deepEqual(normalizeViewState('library', { tag_mode: 'or', folder: 'x'.repeat(1025) }), LIBRARY_DEFAULTS);
assert.deepEqual(libraryFilters({ q: 'a' }), { text: 'a', category: '', sort: 'newest', tags: [], tagMode: 'all', folder: '' });
});
test('a Library view keeps untagged, and untagged never travels with tags (#444)', async () => {
const { normalizeViewState, libraryFilters, libraryViewState } = await source;
assert.equal(normalizeViewState('library', { tag_mode: 'untagged' }).tag_mode, 'untagged');
assert.deepEqual(normalizeViewState('library', { tags: ['a'], tag_mode: 'untagged' }), { ...LIBRARY_DEFAULTS, tags: ['a'] });
const shelf = { text: '', category: '', sort: 'newest', tags: [], tagMode: 'untagged', folder: '' };
assert.equal(libraryViewState(shelf).tag_mode, 'untagged');
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
});
test('a shelf without the #444 filters saves their defaults', async () => {
const { libraryViewState } = await source;
assert.deepEqual(libraryViewState({ text: '', category: '', sort: 'newest', tags: [] }), LIBRARY_DEFAULTS);
});
test('a view saved under the retired Highest rated sort opens on Recently added (D153)', async () => {
const { normalizeViewState, imagesFilters, libraryFilters } = await source;
assert.equal(normalizeViewState('images', { sort: 'rating' }).sort, 'newest');
assert.equal(normalizeViewState('library', { sort: 'rating' }).sort, 'newest');
assert.equal(imagesFilters({ sort: 'rating' }).sort, 'newest');
assert.equal(libraryFilters({ sort: 'rating' }).sort, 'newest');
assert.equal(normalizeViewState('images', { sort: 'name-desc' }).sort, 'name-desc');
assert.equal(normalizeViewState('library', { sort: 'oldest' }).sort, 'oldest');
});
-26
View File
@@ -311,29 +311,3 @@ test('Needs review is a native SAVED VIEWS route titled Review, with its own cou
assert.deepEqual(calls, ['/prompt_manager/review/items?limit=1']);
assert.deepEqual([...counts], [['review', 4]]);
});
// Collections (#331 D137): `#/collection/<id>` opens Images filtered to one collection; its breadcrumb is "Collection".
test('collection links resolve to their id, title Collection, and re-render only when the id changes', async () => {
const {resolveRoute,mountRouter,isHandoff,routeTitle,routes,COLLECTION_ROUTE}=await source;
assert.deepEqual(COLLECTION_ROUTE,{id:'collection',label:'Collection'});
assert.ok(Object.isFrozen(COLLECTION_ROUTE));
const route=resolveRoute('#/collection/7');
assert.deepEqual([route.id,route.collectionId,route.hash,routeTitle(route)],['collection',7,'#/collection/7','Collection']);
assert.equal(isHandoff(route),false);
assert.equal(resolveRoute('#/collection/999999999').collectionId,999999999);
assert.equal(routes.some(item=>item.id==='collection'),false,'a collection is not an Overview card or a WORKSPACE link');
// The listing's and routes' id rule: 1 to 9 digits without a leading zero.
for(const hash of ['#/collection','#/collection/','#/collection/0','#/collection/07','#/collection/-2','#/collection/1.5',
'#/collection/1234567890','#/collection/3/x','#/collection/abc','#/collections/3']) {
assert.equal(resolveRoute(hash).id,'images',hash);
}
const win=new EventTarget();win.location={hash:'#/collection/3'};
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
const seen=[];const dispose=mountRouter(win,next=>seen.push([next.id,next.collectionId]));
win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/collection/4';win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/saved/4';win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/collection/4';win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen,[['collection',3],['collection',4],['saved',undefined],['collection',4]]);
dispose();
});
+9 -25
View File
@@ -65,7 +65,7 @@ function fakeDOM() {
const BROWSER = { workspaceLayout: 'grid', workspaceDensity: 'comfortable', autoLoadMetadata: true, cacheMetadata: true,
imageQuality: 'medium', videoAutoplay: false, videoMute: true, videoLoop: true, showFilePaths: true, showImageInfo: true,
sidebarCollapsedByDefault: false, viewerOpenMode: 'centered' };
sidebarCollapsedByDefault: false };
async function mounted({ host = { webui_display_mode: 'newtab', result_timeout: 5 }, browser = BROWSER, hostFails = false } = {}) {
const { mountAppearance } = await appearanceModule;
@@ -80,9 +80,9 @@ async function mounted({ host = { webui_display_mode: 'newtab', result_timeout:
});
await drafts.load('browser');
const scope = createScope();
const [surface, viewer] = mountAppearance(null, { scope, drafts });
const byId = id => dom.find(surface, node => node.dataset?.setting === id) ?? dom.find(viewer, node => node.dataset?.setting === id);
return { dom, drafts, scope, surface, viewer, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); },
const [surface] = mountAppearance(null, { scope, drafts });
const byId = id => dom.find(surface, node => node.dataset?.setting === id);
return { dom, drafts, scope, surface, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); },
fixHost: () => { hostFails = false; } };
}
@@ -98,26 +98,13 @@ test('the surface is the concept section with the concept rows in order, then th
assert.deepEqual(titles, ['Default layout', 'Thumbnail size', 'Continuous browsing', 'Load metadata on selection',
'Cache metadata in this browser', 'Image loading quality', 'Autoplay videos', 'Mute videos by default', 'Loop videos',
'Show file paths', 'Open from ComfyUI', 'Node search auto-hide (seconds)', 'Show image details on cards',
'Reset display defaults']);
'Collapse viewer details by default', 'Reset display defaults']);
const continuous = rows[2];
assert.deepEqual(dom.tags(continuous.children[0]), ['h3', 'p']);
assert.equal(continuous.children[1].className, 'chip blue');
assert.equal(continuous.children[1].textContent, 'Infinite scroll');
assert.equal(rows.at(-1).className, 'operation');
assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 12);
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
});
// #450 (D156, amends D97): a "Viewer" surface after "Browsing & display" holds the viewer's two defaults.
test('the Viewer surface holds Open viewer in and the moved collapse row', async () => {
const { VIEWER_ROWS } = await appearanceModule;
const { dom, viewer, scope } = await mounted();
try {
assert.equal(viewer.className, 'surface');
assert.equal(viewer.children[0].textContent, 'Viewer');
assert.equal(viewer.children[1].textContent, 'How the image viewer opens; the button beside ✕ switches the current view.');
assert.deepEqual(viewer.children.slice(2).map(row => row.children[0].children[0].textContent), ['Open viewer in', 'Collapse viewer details by default']);
assert.deepEqual(VIEWER_ROWS.map(row => row[0]), ['viewer-mode', 'viewer-details']);
assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 13);
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
});
@@ -140,11 +127,9 @@ test('added rows reach their consumers: each control edits its stored key with t
byId(id).checked = false; byId(id).dispatch('change');
}
for (const id of ['video-auto', 'viewer-details']) { byId(id).checked = true; byId(id).dispatch('change'); }
assert.deepEqual(byId('viewer-mode').children.map(option => [option.value, option.textContent]), [['centered', 'Centered'], ['fullscreen', 'Full screen']]);
byId('viewer-mode').value = 'fullscreen'; byId('viewer-mode').dispatch('change');
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', workspaceDensity: 'large', imageQuality: 'low',
autoLoadMetadata: false, cacheMetadata: false, videoMute: false, videoLoop: false, showFilePaths: false, showImageInfo: false,
videoAutoplay: true, sidebarCollapsedByDefault: true, viewerOpenMode: 'fullscreen' });
videoAutoplay: true, sidebarCollapsedByDefault: true });
assert.deepEqual(drafts.changes('host'), { webui_display_mode: 'popup' });
} finally { scope.dispose(); dom.restore(); }
});
@@ -169,7 +154,7 @@ test('the auto-hide timeout takes whole seconds from 0 to 300 only', async () =>
test('reset restores defaults without touching other keys', async () => {
const { DISPLAY_KEYS } = await appearanceModule;
const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true, viewerOpenMode: 'fullscreen' };
const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true };
const { dom, drafts, scope, surface, loadHost } = await mounted({ browser: changed });
try {
await loadHost();
@@ -177,8 +162,7 @@ test('reset restores defaults without touching other keys', async () => {
const reset = dom.find(surface.children.at(-1), node => node.tagName === 'BUTTON');
assert.equal(reset.textContent, 'Reset');
reset.click();
// #451: the default layout is Masonry.
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false, viewerOpenMode: 'centered' });
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'grid', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false });
// The host fields and every key outside the display defaults are left as they are.
assert.deepEqual(drafts.changes('host'), { result_timeout: 9 });
assert.deepEqual([...DISPLAY_KEYS].sort(), Object.keys(BROWSER).sort());
-65
View File
@@ -192,71 +192,6 @@ test('views reload once after a restore this page saw finish; a failed restore c
assert.equal(listeners.size, 0);
});
// #415: a fake document whose open dialogs the test sets, and a MutationObserver stand-in it triggers.
function dialogPage() {
const page = { open: 0, observers: new Set() };
page.doc = { documentElement: {}, querySelector: selector => (selector === 'dialog[open]' && page.open > 0 ? {} : null) };
page.Observer = class {
constructor(callback) { this.callback = callback; }
observe(target, options) { this.target = target; this.options = options; page.observers.add(this); }
disconnect() { page.observers.delete(this); }
};
page.mutate = open => { page.open = open; for (const observer of [...page.observers]) observer.callback([]); };
return page;
}
test('the restore reload runs at once when no dialog is open', async () => {
const { createRestoreReload } = await backups;
const page = dialogPage();
let reloads = 0;
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
reload.request();
assert.equal(reloads, 1);
assert.equal(page.observers.size, 0, 'nothing waits');
reload.request();
assert.equal(reloads, 2);
});
test('the restore reload waits for every open dialog to close, then runs once (#415)', async () => {
const { createRestoreReload } = await backups;
const page = dialogPage();
page.open = 1;
let reloads = 0;
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
reload.request();
reload.request();
assert.equal(reloads, 0, 'an open dialog holds the reload');
assert.equal(page.observers.size, 1, 'one observer for repeated requests');
const [observer] = page.observers;
assert.equal(observer.target, page.doc.documentElement);
assert.deepEqual(observer.options, { subtree: true, childList: true, attributes: true, attributeFilter: ['open'] });
page.mutate(1);
assert.equal(reloads, 0, 'a dialog swapped for another keeps it waiting');
page.mutate(0);
assert.equal(reloads, 1);
assert.equal(page.observers.size, 0, 'the observer stops once it ran');
page.mutate(0);
assert.equal(reloads, 1);
page.open = 1;
reload.request();
page.mutate(0);
assert.equal(reloads, 2, 'a later restore waits again');
});
test('disposing drops a waiting restore reload', async () => {
const { createRestoreReload } = await backups;
const page = dialogPage();
page.open = 1;
let reloads = 0;
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
reload.request();
reload.dispose();
assert.equal(page.observers.size, 0);
page.mutate(0);
assert.equal(reloads, 0);
reload.dispose();
});
test('a downloaded backup the server could not verify says so', async () => {
const { downloadDatabaseBackup, BACKUP_DONE, backupUnverified } = await scan;
const toasts = [];
+3 -3
View File
@@ -141,7 +141,7 @@ test('the report summary says when it was checked and whether it is complete', a
assert.equal(healthSummary({ ...REPORT, checked_at: 'x' }), 'Nothing was changed; review the checks with issues.');
});
test('statistics rows are the real runs, then the library totals, without ratings (D153)', async () => {
test('statistics rows are the real runs and ratings, then the library totals', async () => {
const { categoryRows, totalsRows } = await overviewModule;
const statistics = {
by_category: [{ category: 'Landscapes', runs: 1200, average_rating: 4.5 },
@@ -149,8 +149,8 @@ test('statistics rows are the real runs, then the library totals, without rating
totals: { total_prompts: 12, total_tags: 15, total_images: 24 },
rated_images: 20,
};
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString()], ['Uncategorized', '3']]);
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24']]);
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString(), '4.5 / 5'], ['Uncategorized', '3', 'Not rated']]);
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24'], ['Rated images', '20']]);
});
/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */
-31
View File
@@ -594,9 +594,6 @@ test('an input dialog offers suggestions and a secondary action', async () => {
const [plainField, plainActions] = byId(dom, 'modal-body').children;
assert.equal(plainField.children[1].getAttribute('list'), null);
assert.deepEqual(plainActions.children.map(node => node.textContent), ['Cancel', 'A']);
// An empty list (Add to collection with no collections yet, #331 D138) adds no datalist either.
openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [], onApply() {} });
assert.equal(dom.all(node => node.tagName === 'DATALIST').length, 0);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [1], onApply() {} }), TypeError);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: '' }, onApply() {} }), TypeError);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: 'X' }, onApply() {} }), TypeError);
@@ -911,34 +908,6 @@ test('openReviewDialog lays out heading, grid, options, sentence, backup box and
}
});
// #415: a finished restore ends an open review (its reviewed plan no longer describes storage) as a dismissal, and
// leaves any other dialog, with what was typed in it, open.
test('closeReviewDialog closes only an open review, as dismissed', async () => {
const { openReviewDialog, openInputDialog, closeReviewDialog, closeWorkspaceDialog } = await dialogsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
try {
closeReviewDialog();
const outcomes = [];
openInputDialog({ scope, title: 'Save view', label: 'Name', value: 'typed', action: 'Save', onApply() {} });
closeReviewDialog();
assert.equal(modal(dom).open, true, 'an input dialog stays');
assert.equal(byId(dom, 'action-input').value, 'typed');
openReviewDialog({ scope, title: 'Restore preview', grid: [['Integrity', 'Verified']], cancelLabel: 'Cancel',
primary: { label: 'Restore', danger: true }, onStart: () => outcomes.push('start'), onDismiss: () => outcomes.push('dismiss') });
closeReviewDialog();
assert.deepEqual(outcomes, ['dismiss']);
assert.equal(modal(dom), undefined, 'the shell is gone');
closeReviewDialog();
assert.deepEqual(outcomes, ['dismiss']);
} finally {
scope.dispose();
closeWorkspaceDialog();
dom.restore();
}
});
test('review dialog backup box keeps the dialog open and toasts the outcome', async () => {
const { openReviewDialog, closeWorkspaceDialog, SCAN_BACKUP_COPY } = await dialogsModule;
const { downloadDatabaseBackup, BACKUP_DONE, BACKUP_FAILED } = await scanModule;
@@ -461,57 +461,6 @@ test('after dispose an unconfirmed write invalidates and a failed write does not
}
});
test('a tag stream sends its search and sort, replaces them on reset and needs the server to echo them (#444)', async () => {
const {createVocabularyStream, TAG_SORTS} = await source;
assert.deepEqual(TAG_SORTS.map(([value]) => value), ['created', 'name', 'name-desc', 'prompts', 'prompts-asc']);
assert.deepEqual(TAG_SORTS.map(([, label]) => label), ['Oldest first', 'Name A–Z', 'Name Z–A', 'Most prompts', 'Fewest prompts']);
const echo = (page, search, sort) => ({...page, search, sort, untagged_count: 4});
const {calls, api} = queued([
echo(tagPage([tag(1)], 'next'), 'cat', 'name'), echo(tagPage([tag(2)]), 'cat', 'name'),
echo(tagPage([tag(3)]), '', 'created'), tagPage([tag(4)]), echo(tagPage([tag(5)]), 'dog', 'created'),
]);
const stream = createVocabularyStream({api, kind: 'tags', query: {search: ' cat ', sort: 'name'}});
await stream.loadMore();
assert.equal(params(calls[0].url).get('search'), 'cat');
assert.equal(params(calls[0].url).get('sort'), 'name');
assert.equal(stream.state.untaggedCount, 4);
await stream.loadMore();
assert.equal(params(calls[1].url).get('search'), 'cat');
assert.equal(params(calls[1].url).get('cursor'), 'next');
assert.deepEqual(stream.state.query, {search: 'cat', sort: 'name'});
// The default query sends neither, so an older server answers it unchanged.
await stream.reset({search: '', sort: 'created'});
assert.equal(params(calls[2].url).has('search'), false);
assert.equal(params(calls[2].url).has('sort'), false);
assert.deepEqual(stream.state.items.map(row => row.id), [3]);
// A server that ignored the search (no echo) is an error, never a wrong list.
await stream.reset({search: 'dog', sort: 'created'});
assert.deepEqual(stream.state.items, []);
assert.ok(stream.state.error instanceof TypeError);
assert.equal(stream.state.untaggedCount, 4, 'the last known untagged count survives a reset');
await stream.loadMore();
assert.deepEqual(stream.state.items.map(row => row.id), [5]);
assert.throws(() => stream.reset({search: 'x', sort: 'count'}), TypeError);
assert.throws(() => createVocabularyStream({api, kind: 'categories', query: {search: 'x'}}), TypeError);
assert.throws(() => createVocabularyStream({api, kind: 'tags', query: {search: 3}}), TypeError);
stream.dispose();
});
test('an invalid untagged_count rejects the page', async () => {
const {createVocabularyStream} = await source;
for (const untagged_count of [-1, 1.5, '2']) {
const stream = createVocabularyStream({api: queued([{...tagPage([tag(1)]), untagged_count}]).api, kind: 'tags'});
await stream.loadMore();
assert.ok(stream.state.error instanceof TypeError, String(untagged_count));
assert.equal(stream.state.untaggedCount, null);
}
});
test('untaggedBrowseFilters opens the Library on prompts without tags (#444)', async () => {
const {untaggedBrowseFilters} = await source;
assert.deepEqual(untaggedBrowseFilters(), {text: '', category: '', tags: [], sort: 'newest', tagMode: 'untagged'});
});
test('browseFilters hands Library one exact constraint', async () => {
const {browseFilters} = await source;
assert.deepEqual(browseFilters('tags', 'a'), {text: '', category: '', tags: ['a'], sort: 'newest'});
+9 -15
View File
@@ -2205,30 +2205,24 @@ class TestOutputScanInternals(AdminAPITestCase):
self.assertEqual(self.api._ingest_scanned_file(f, meta), "unchanged")
def test_ingest_link_failures_are_tolerated(self):
"""A failed link rolls the new prompt back: found, nothing left behind."""
f = Path("gen.png")
meta = {"prompt": json.dumps(_wired_prompt("a dog"))}
self.api.db._insert_image_link = _raise
with self.assertLogs("prompt_manager.database.scanned_prompts", "ERROR"):
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
self.api.db.link_image_to_prompt = _raise
self.assertEqual(self.api._ingest_scanned_file(f, meta), "added")
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
def test_ingest_save_failure_counts_as_found(self):
f = Path("gen.png")
meta = {"prompt": json.dumps(_wired_prompt("a bird"))}
with patch(
"py.api.admin.store_scanned_prompt", return_value=("found", None)
) as store:
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
self.assertEqual(store.call_args.args[3], "a bird")
self.api.db.save_prompt = lambda *a, **k: None
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
def test_batch_counts_and_per_file_errors(self):
meta = {"prompt": json.dumps(_wired_prompt("a fish"))}
with patch("py.api.admin.store_scanned_prompt", side_effect=_raise):
counts = self.api._ingest_scan_batch_sync(
[(Path("a.png"), meta), (Path("b.png"), {})]
)
self.api.db.get_prompt_by_hash = _raise
counts = self.api._ingest_scan_batch_sync(
[(Path("a.png"), meta), (Path("b.png"), {})]
)
self.assertEqual(
counts,
{
+3 -4
View File
@@ -2556,10 +2556,9 @@ class TestRootScopeServing(ImageAPITestCase):
listing = await self.client.get("/prompt_manager/images/output?mode=workspace")
descriptor = (await listing.json())["images"][0]
self.assertRegex(descriptor.get("root_scope", ""), r"^[0-9a-f]{64}$")
from utils.workspace_keys import WorkspaceKeys
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
with patch.object(self.api, "_workspace_keys", return_value=rotated):
with patch.object(
images_module, "_WORKSPACE_CURSOR_SECRET", b"different-process-key"
):
await self.assert_no_access("image.png", scope=descriptor["root_scope"])
self.api._get_all_output_dirs = lambda: []
await self.assert_no_access("image.png", scope=descriptor["root_scope"])
-92
View File
@@ -60,32 +60,6 @@ class TestLibraryAPI(ImageAPITestCase):
self.assertEqual(response.status, 400)
self.assertNotIn(str(self.comfy_dir), json.dumps(result))
async def test_untagged_tag_mode_lists_parents_without_tags(self):
self._save_prompt("plain one")
self._save_prompt("plain two")
self._save_prompt("tagged", tags=["t"])
self.api.db.save_prompt_pair("pair tagged", "noise", tags=["p"])
_, first = await self.get(limit=1, tag_mode="untagged")
self.assertEqual(first["total"], 2)
self.assertEqual(first["tag_mode"], "untagged")
_, second = await self.get(
limit=1, tag_mode="untagged", cursor=first["next_cursor"]
)
self.assertEqual(
sorted([first["items"][0]["text"], second["items"][0]["text"]]),
["plain one", "plain two"],
)
_, default = await self.get(tag_mode="all")
self.assertEqual(default["total"], 4)
for params in (
{"tag_mode": "untagged", "tag": "t"},
{"tag_mode": "sql"},
{"cursor": first["next_cursor"]},
):
response, result = await self.get(limit=1, **params)
self.assertEqual(response.status, 400, params)
self.assertEqual(result["success"], False)
async def test_outside_missing_and_unsupported_media_do_not_leak_or_scan(self):
positive = self._save_prompt("Cat")
outside = make_png(self.comfy_dir / "outside.png")
@@ -123,72 +97,6 @@ class TestLibraryAPI(ImageAPITestCase):
},
)
async def test_folder_filter_lists_and_matches_prompts_by_output_folder(self):
deep = self._save_prompt("deep")
top = self._save_prompt("top")
other = self._save_prompt("other")
self._save_prompt("no images")
for prompt, parts in ((deep, ("a", "b")), (top, ("a",)), (other, ("ab",))):
path = make_png(self.output_dir.joinpath(*parts, f"{prompt}.png"))
self.api.db.link_image_to_prompt(prompt, str(path))
response, options = await self.get("/options", include="folders")
self.assertEqual(response.status, 200)
self.assertEqual(options["folders"], ["a", "a/b", "ab"])
_, plain = await self.get("/options")
self.assertNotIn("folders", plain)
_, first = await self.get(folder="a", limit=1)
self.assertEqual((first["total"], first["folder"]), (2, "a"))
# Outer slashes are dropped (a backslash separates only on Windows).
outer = "\\a\\" if os.name == "nt" else "/a/"
_, second = await self.get(folder=outer, limit=1, cursor=first["next_cursor"])
self.assertEqual(
sorted([first["items"][0]["text"], second["items"][0]["text"]]),
["deep", "top"],
)
_, all_rows = await self.get()
self.assertEqual((all_rows["total"], all_rows["folder"]), (4, ""))
for params in (
{"folder": "a/../ab"},
*([{"folder": "C:/x"}] if os.name == "nt" else []),
{"folder": "b", "cursor": first["next_cursor"]},
{"cursor": first["next_cursor"]},
):
response, result = await self.get(limit=1, **params)
self.assertEqual(response.status, 400, params)
self.assertEqual(result["success"], False)
response, _ = await self.get("/options", include="tags")
self.assertEqual(response.status, 400)
@unittest.skipIf(os.name == "nt", "a backslash is a separator on Windows")
async def test_a_posix_folder_name_with_a_backslash_filters_its_prompts(self):
prompt = self._save_prompt("klein")
name = "Metadata\\Klein_4b Wildcard Run"
path = make_png(self.output_dir / name / "x.png")
self.api.db.link_image_to_prompt(prompt, str(path))
_, options = await self.get("/options", include="folders")
self.assertIn(name, options["folders"])
_, result = await self.get(folder=name)
self.assertEqual((result["total"], result["folder"]), (1, name))
async def test_any_tag_mode_is_sent_through_and_echoed(self):
self._save_prompt("a only", tags=["a"])
self._save_prompt("b only", tags=["b"])
self._save_prompt("neither")
_, result = await self.get(tag=["a", "b"], tag_mode="any")
self.assertEqual(result["tag_mode"], "any")
self.assertEqual(
sorted(i["text"] for i in result["items"]), ["a only", "b only"]
)
_, both = await self.get(tag=["a", "b"])
self.assertEqual((both["total"], both["tag_mode"]), (0, "all"))
_, first = await self.get(tag=["a", "b"], tag_mode="any", limit=1)
response, _ = await self.get(
tag=["a", "b"], limit=1, cursor=first["next_cursor"]
)
self.assertEqual(response.status, 400)
response, _ = await self.get(tag_mode="or")
self.assertEqual(response.status, 400)
async def test_variant_cursor_is_positive_scoped_and_required_limits_are_bounded(
self,
):
+2 -3
View File
@@ -1418,10 +1418,9 @@ class TestPromptInspector(ImageAPITestCase):
async def test_root_scope_rotated_key_and_removed_index_skip_reader_and_db(self):
png(self.output_dir / "image.png", prompt=graph())
descriptor = await self.scoped_descriptor()
from utils.workspace_keys import WorkspaceKeys
import py.api.images as images_module
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
with patch.object(self.api, "_workspace_keys", return_value=rotated):
with patch.object(images_module, "_WORKSPACE_CURSOR_SECRET", b"new-key"):
await self.assert_scope_skips_access(descriptor.get("root_scope", "0" * 64))
self.api._get_all_output_dirs = lambda: []
await self.assert_scope_skips_access(descriptor.get("root_scope", "0" * 64))
-169
View File
@@ -1,169 +0,0 @@
"""POST /prompt_manager/images/rescope: a stale target's current identity (#430).
A page that holds ``{root_index, root_scope, path}`` after the sources were
renumbered asks where that file is now. The server maps the target only when
its scope was issued, at its old index, for one of the current roots, so the
answer names nothing the page could not already address; a removed or
replaced root, a foreign scope or a changed key stay refused.
"""
import json
import os
import sys
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from test_api_images import ImageAPITestCase, make_png
except ImportError: # imported as tests.test_api_rescope
from tests.test_api_images import ImageAPITestCase, make_png
from utils.workspace_keys import WorkspaceKeys # noqa: E402
RESCOPE = "/prompt_manager/images/rescope"
class TestRescopeRoute(ImageAPITestCase):
async def get_application(self):
app = await super().get_application()
self.other_dir = self.comfy_dir / "other"
self.other_dir.mkdir()
make_png(self.output_dir / "sub" / "a.png")
make_png(self.other_dir / "sub" / "a.png", color=(0, 0, 255))
return app
def target(self, path="sub/a.png", index=0, root=None):
root = root or self.output_dir
return {
"root_index": index,
"root_scope": self.api._gallery_root_scope(root, index),
"path": path,
}
async def post(self, body):
response = await self.client.post(RESCOPE, json=body)
data = await response.json()
self.assertNotIn(str(self.comfy_dir), json.dumps(data))
self.assertEqual(response.headers.get("Cache-Control"), "no-store")
return response.status, data
async def listed(self):
response = await self.client.get(
"/prompt_manager/images/output", params={"mode": "workspace"}
)
return {
(item["root_index"], item["path"]): item
for item in (await response.json())["images"]
}
async def test_an_unchanged_root_answers_the_listing_descriptor(self):
status, data = await self.post({"target": self.target()})
self.assertEqual(status, 200)
self.assertTrue(data["success"])
self.assertEqual(data["image"], (await self.listed())[(0, "sub/a.png")])
async def test_a_renumbered_root_maps_to_its_new_index(self):
held = self.target()
self.api._get_all_output_dirs = lambda: [self.other_dir, self.output_dir]
status, data = await self.post({"target": held})
self.assertEqual(status, 200)
image = data["image"]
self.assertEqual((image["root_index"], image["path"]), (1, "sub/a.png"))
self.assertEqual(
image["root_scope"], self.api._gallery_root_scope(self.output_dir, 1)
)
self.assertEqual(image, (await self.listed())[(1, "sub/a.png")])
async def test_a_removed_replaced_or_foreign_root_is_source_changed(self):
held = self.target()
cases = {
"removed": lambda: [],
"replaced": lambda: [self.other_dir],
}
for name, roots in cases.items():
with self.subTest(name):
self.api._get_all_output_dirs = roots
status, data = await self.post({"target": held})
self.assertEqual((status, data["code"]), (409, "source-changed"))
self.api._get_all_output_dirs = lambda: [self.output_dir]
for scope in ("0" * 64, self.api._gallery_root_scope(self.output_dir, 1)):
with self.subTest(scope=scope[:8]):
status, data = await self.post(
{"target": {**held, "root_scope": scope}}
)
self.assertEqual((status, data["code"]), (409, "source-changed"))
async def test_a_changed_key_cannot_be_mapped(self):
held = self.target()
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
with patch.object(self.api, "_workspace_keys", return_value=rotated):
status, data = await self.post({"target": held})
self.assertEqual((status, data["code"]), (409, "source-changed"))
async def test_the_first_of_two_spellings_of_one_root_wins(self):
held = self.target()
self.api._get_all_output_dirs = lambda: [
self.other_dir,
self.output_dir,
self.output_dir / "sub" / "..",
]
status, data = await self.post({"target": held})
self.assertEqual((status, data["image"]["root_index"]), (200, 1))
async def test_a_missing_file_is_not_found(self):
status, data = await self.post({"target": self.target("sub/gone.png")})
self.assertEqual((status, data["code"]), (404, "file-missing"))
(self.output_dir / "dir.png").mkdir()
status, data = await self.post({"target": self.target("dir.png")})
self.assertEqual((status, data["code"]), (404, "file-missing"))
async def test_a_path_leaving_the_root_is_refused(self):
outside = make_png(self.comfy_dir / "outside.png")
self._make_symlink(outside, self.output_dir / "escape.png")
status, _data = await self.post({"target": self.target("escape.png")})
self.assertEqual(status, 403)
async def test_previews_are_not_rescoped(self):
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
status, _data = await self.post(
{"target": self.target("thumbnails/a_thumb.png")}
)
self.assertEqual(status, 403)
async def test_malformed_bodies_are_refused_before_any_file_access(self):
good = self.target()
bodies = [
b"{",
[],
{},
{"target": good, "extra": 1},
{"target": {**good, "root_index": -1}},
{"target": {**good, "root_index": True}},
{"target": {**good, "root_index": 4096}},
# Past Python's int-to-text digit limit: never a 500.
b'{"target": {"root_index": 1' + b"0" * 5000 + b"}}",
{"target": {**good, "root_scope": "A" * 64}},
{"target": {**good, "root_scope": "abc"}},
{"target": {**good, "path": ""}},
{"target": {**good, "path": "../a.png"}},
{"target": {**good, "path": "/abs.png"}},
{"target": {**good, "path": "C:/a.png"}},
{"target": {**good, "path": "sub\\a.png"}},
{"target": {**good, "path": "sub//a.png"}},
{"target": {**good, "path": "notes.txt"}},
{"target": {**good, "path": "x" * 2000 + ".png"}},
]
with patch("pathlib.Path.is_file", side_effect=AssertionError("file access")):
for body in bodies:
with self.subTest(body=str(body)[:60]):
if isinstance(body, bytes):
response = await self.client.post(
RESCOPE,
data=body,
headers={"Content-Type": "application/json"},
)
else:
response = await self.client.post(RESCOPE, json=body)
self.assertIn(response.status, (400, 403))
self.assertFalse((await response.json())["success"])
+1 -46
View File
@@ -35,17 +35,7 @@ class TestVocabularyAPI(ImageAPITestCase):
self.assertEqual(response.status, 200)
self.assertEqual(
set(first),
{
"success",
"library_epoch",
"tags",
"total",
"untagged_count",
"search",
"sort",
"has_more",
"next_cursor",
},
{"success", "library_epoch", "tags", "total", "has_more", "next_cursor"},
)
self.assertEqual(first["library_epoch"], epoch)
self.assertEqual(first["total"], 3)
@@ -121,41 +111,6 @@ class TestVocabularyAPI(ImageAPITestCase):
response, _ = await self.get("tags", limit=1, cursor=fresh["next_cursor"])
self.assertEqual(response.status, 200)
async def test_tag_search_and_sort_page_with_scoped_cursors(self):
self._save_prompt("One", tags=["beta", "alpha"])
self._save_prompt("Two", tags=["beta"])
self._save_prompt("Plain")
self.add_unused("gamma")
_, first = await self.get("tags", limit=1, sort="name", search="A")
self.assertEqual([row["name"] for row in first["tags"]], ["alpha"])
self.assertEqual(first["total"], 3)
self.assertEqual(first["untagged_count"], 1)
self.assertEqual((first["search"], first["sort"]), ("A", "name"))
_, second = await self.get(
"tags", limit=1, sort="name", search="A", cursor=first["next_cursor"]
)
self.assertEqual([row["name"] for row in second["tags"]], ["beta"])
_, ranked = await self.get("tags", sort="prompts")
self.assertEqual(
[(row["name"], row["prompt_count"]) for row in ranked["tags"]],
[("beta", 2), ("alpha", 1), ("gamma", 0)],
)
# A cursor belongs to one search and one sort.
for params in (
{"sort": "name-desc", "search": "A"},
{"sort": "name", "search": "B"},
{"sort": "created", "search": "A"},
):
response, result = await self.get(
"tags", limit=1, cursor=first["next_cursor"], **params
)
self.assertEqual(response.status, 400, params)
self.assertIn("refresh", result["error"])
for params in ({"sort": "count"}, {"search": "x" * 201}):
response, result = await self.get("tags", **params)
self.assertEqual(response.status, 400, params)
self.assertEqual(result["success"], False)
async def test_read_failures_keep_the_read_envelope(self):
with patch.object(
self.api.db,
+2 -6
View File
@@ -1882,13 +1882,9 @@ class TestWorkspaceCursorEncoding(unittest.TestCase):
for order in ("newest", "oldest", "name", "name-desc"):
with self.subTest(sort=order):
key = _workspace_key(entry, order)
token = _workspace_cursor(key, "scope", b"k" * 32)
token = _workspace_cursor(key, "scope")
self.assertLessEqual(len(token), 4096)
self.assertEqual(
_workspace_cursor_key(token, "scope", order, b"k" * 32), key
)
with self.assertRaises(ValueError):
_workspace_cursor_key(token, "scope", order, b"o" * 32)
self.assertEqual(_workspace_cursor_key(token, "scope", order), key)
# The pre-#379 candidate read, kept as the differential reference for the

Some files were not shown because too many files have changed in this diff Show More