Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db5a1dc131 |
@@ -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
File diff suppressed because it is too large
Load Diff
+16
-84
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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']));
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]]);
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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 });
|
||||
});
|
||||
@@ -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]');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
|
||||
@@ -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])));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']]);
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"])
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user