Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe9a047dc8 | ||
|
|
dbdc8cd819 | ||
|
|
e724cf18b4 | ||
|
|
16b4c1a7ec | ||
|
|
fae047bb87 | ||
|
|
cf011d1365 | ||
|
|
0aa496876e | ||
|
|
caa9649f32 | ||
|
|
3870590704 |
@@ -9,6 +9,9 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
PYTHONFAULTHANDLER: "1"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: py${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
|
||||
@@ -162,6 +162,12 @@ when the fields are collapsed, so LoRA trigger outputs can connect directly.
|
||||
Encoding, storage, and CONDITIONING/text outputs match Classic. Both layouts
|
||||
support ComfyUI Classic and Nodes 2.0.
|
||||
|
||||
**Prompt Manager (Split)** keeps the same editor and library visible together.
|
||||
Wide nodes place the library beside the editor; narrow nodes stack the panels.
|
||||
It shares Focused's category/tag autocomplete, search filters, preview-before-use,
|
||||
workflow persistence, and connectable prepend/append inputs. Encoding and storage
|
||||
match Classic and Focused.
|
||||
|
||||
### 🔬 ComfyUI Workflow Analysis
|
||||
|
||||
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
|
||||
|
||||
@@ -128,3 +128,25 @@ gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required
|
||||
|
||||
Update the manifest whenever required job names change. Keep the frontend check
|
||||
free of path filters so a required check is never silently skipped.
|
||||
|
||||
## Unified workspace shell
|
||||
|
||||
The opt-in `/prompt_manager/workspace` page uses the shipped toolkit and real
|
||||
`/prompt_manager/stats` API. Existing admin, gallery and metadata URLs remain
|
||||
available while replacement workspaces are developed. Browser smoke tests in
|
||||
`tests/browser/workspace.spec.mjs` cover navigation/history, keyboard skip links,
|
||||
mobile layout, theme persistence/fallback, loading/retry and stale responses.
|
||||
They run automatically in the existing four-browser-project workflow and frontend
|
||||
coverage job. `tests/comfyui/workspace.spec.mjs` verifies the page against the
|
||||
managed host with both renderer startup configurations.
|
||||
|
||||
Run the focused page checks with:
|
||||
|
||||
```sh
|
||||
npm run --prefix tools/vendor test:workflows -- workspace.spec.mjs
|
||||
node --test tests/js/workspace-*.test.js
|
||||
```
|
||||
|
||||
Use the disposable fixture and managed-host prerequisites above. A maintainer
|
||||
live check needs a ComfyUI restart after adding the Python route, then opening
|
||||
`/prompt_manager/workspace`; the mockup preview server is a separate design tool.
|
||||
|
||||
@@ -6,6 +6,7 @@ gallery system.
|
||||
This module provides prompt node types:
|
||||
- PromptManager: CLIP encoding node that outputs CONDITIONING
|
||||
- PromptManagerFocused: Editor-first layout using the same encoding contract
|
||||
- PromptManagerSplit: Side-by-side editor and library using the same contract
|
||||
- PromptManagerText: Text-only node that outputs STRING with prepend/append
|
||||
functionality
|
||||
|
||||
@@ -39,13 +40,20 @@ def get_version():
|
||||
return "unknown"
|
||||
|
||||
|
||||
from .prompt_manager import PromptManager, PromptManagerFocused # noqa: E402
|
||||
from .prompt_manager import ( # noqa: E402
|
||||
PromptManager,
|
||||
PromptManagerFocused,
|
||||
PromptManagerSplit,
|
||||
)
|
||||
from .prompt_manager_pair import PromptManagerPair # noqa: E402
|
||||
from .prompt_manager_text import PromptManagerText # noqa: E402
|
||||
from .prompt_search_list import PromptSearchList # noqa: E402
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PromptManager": PromptManager,
|
||||
"PromptManagerFocused": PromptManagerFocused,
|
||||
"PromptManagerSplit": PromptManagerSplit,
|
||||
"PromptManagerPair": PromptManagerPair,
|
||||
"PromptManagerText": PromptManagerText,
|
||||
"PromptSearchList": PromptSearchList,
|
||||
}
|
||||
@@ -53,6 +61,8 @@ NODE_CLASS_MAPPINGS = {
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PromptManager": "Prompt Manager (Classic)",
|
||||
"PromptManagerFocused": "Prompt Manager (Focused)",
|
||||
"PromptManagerSplit": "Prompt Manager (Split)",
|
||||
"PromptManagerPair": "Prompt Manager (+/-)",
|
||||
"PromptManagerText": "Prompt Manager Text",
|
||||
"PromptSearchList": "Prompt Search List",
|
||||
}
|
||||
|
||||
@@ -4,14 +4,17 @@ Database schema and models for KikoTextEncode prompt storage.
|
||||
|
||||
import contextlib
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import sqlite3
|
||||
import os
|
||||
import threading
|
||||
import tempfile
|
||||
import weakref
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import Set, Tuple
|
||||
from typing import Optional, Set, Tuple
|
||||
|
||||
# Import logging system
|
||||
try:
|
||||
@@ -29,6 +32,41 @@ except ImportError:
|
||||
ISO_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%f+00:00"
|
||||
|
||||
|
||||
MAX_PAIR_PROVENANCE_LENGTH = 4_000_000
|
||||
MAX_PAIR_PROVENANCE_DEPTH = 64
|
||||
|
||||
|
||||
def pair_provenance_error(encoded: object) -> Optional[str]:
|
||||
"""Validate the same bounded JSON object for live writes and restores."""
|
||||
if not isinstance(encoded, str):
|
||||
return "Pair provenance must be JSON text"
|
||||
if len(encoded) > MAX_PAIR_PROVENANCE_LENGTH:
|
||||
return "Pair provenance exceeds the size limit"
|
||||
try:
|
||||
value = json.loads(encoded)
|
||||
except (ValueError, TypeError, RecursionError):
|
||||
return "Pair provenance is not readable JSON"
|
||||
if not isinstance(value, dict):
|
||||
return "Pair provenance must be a JSON object"
|
||||
pending = [(iter((value,)), 0)]
|
||||
while pending:
|
||||
children, depth = pending[-1]
|
||||
try:
|
||||
item = next(children)
|
||||
except StopIteration:
|
||||
pending.pop()
|
||||
continue
|
||||
if depth > MAX_PAIR_PROVENANCE_DEPTH:
|
||||
return "Pair provenance exceeds the nesting limit"
|
||||
if isinstance(item, float) and not math.isfinite(item):
|
||||
return "Pair provenance contains nonfinite numbers"
|
||||
if isinstance(item, dict):
|
||||
pending.append((iter(item.values()), depth + 1))
|
||||
elif isinstance(item, list):
|
||||
pending.append((iter(item), depth + 1))
|
||||
return None
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
"""Current UTC time as ISO text with microseconds.
|
||||
|
||||
@@ -235,6 +273,224 @@ class PromptModel:
|
||||
|
||||
# Image uniqueness by full path instead of basename (3.2.4)
|
||||
self._migrate_image_uniqueness_by_path(conn)
|
||||
self._migrate_prompt_pairs(conn)
|
||||
|
||||
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
|
||||
conn.execute("SAVEPOINT prompt_pair_schema")
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS prompt_pairs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
positive_prompt_id INTEGER NOT NULL,
|
||||
negative_prompt_id INTEGER,
|
||||
title TEXT,
|
||||
category TEXT,
|
||||
rating INTEGER CHECK(rating BETWEEN 1 AND 5),
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT,
|
||||
run_count INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1)),
|
||||
FOREIGN KEY(positive_prompt_id)
|
||||
REFERENCES prompts(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY(negative_prompt_id)
|
||||
REFERENCES prompts(id) ON DELETE RESTRICT,
|
||||
UNIQUE(positive_prompt_id, negative_prompt_id)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS prompt_pair_tags (
|
||||
pair_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
PRIMARY KEY(pair_id, tag_id),
|
||||
FOREIGN KEY(pair_id) REFERENCES prompt_pairs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS image_prompt_pairs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
image_id INTEGER NOT NULL,
|
||||
pair_id INTEGER NOT NULL,
|
||||
provenance TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(image_id)
|
||||
REFERENCES generated_images(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(pair_id) REFERENCES prompt_pairs(id) ON DELETE RESTRICT,
|
||||
UNIQUE(pair_id, image_id, provenance)
|
||||
)
|
||||
""")
|
||||
self._create_pair_indexes(conn)
|
||||
error = self._pair_schema_error(conn)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute("RELEASE prompt_pair_schema")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK TO prompt_pair_schema")
|
||||
conn.execute("RELEASE prompt_pair_schema")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _create_pair_indexes(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_prompt_pairs_positive_only "
|
||||
"ON prompt_pairs(positive_prompt_id) WHERE negative_prompt_id IS NULL"
|
||||
)
|
||||
for name, target in (
|
||||
("idx_prompt_pairs_negative", "prompt_pairs(negative_prompt_id)"),
|
||||
("idx_prompt_pair_tags_tag", "prompt_pair_tags(tag_id)"),
|
||||
("idx_image_prompt_pairs_image", "image_prompt_pairs(image_id)"),
|
||||
):
|
||||
conn.execute(f"CREATE INDEX IF NOT EXISTS {name} ON {target}")
|
||||
|
||||
@staticmethod
|
||||
def _pair_schema_error(conn: sqlite3.Connection) -> Optional[str]:
|
||||
required = {
|
||||
"prompt_pairs": {
|
||||
"id",
|
||||
"positive_prompt_id",
|
||||
"negative_prompt_id",
|
||||
"title",
|
||||
"category",
|
||||
"rating",
|
||||
"notes",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"last_used_at",
|
||||
"run_count",
|
||||
"archived",
|
||||
},
|
||||
"prompt_pair_tags": {"pair_id", "tag_id"},
|
||||
"image_prompt_pairs": {
|
||||
"id",
|
||||
"image_id",
|
||||
"pair_id",
|
||||
"provenance",
|
||||
"created_at",
|
||||
},
|
||||
}
|
||||
present = {
|
||||
row[0]
|
||||
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
if not present.intersection(required):
|
||||
return None
|
||||
if not set(required).issubset(present):
|
||||
return "Incomplete prompt pair schema"
|
||||
foreign_keys = {
|
||||
"prompt_pairs": {
|
||||
("positive_prompt_id", "prompts", "id", "RESTRICT"),
|
||||
("negative_prompt_id", "prompts", "id", "RESTRICT"),
|
||||
},
|
||||
"prompt_pair_tags": {
|
||||
("pair_id", "prompt_pairs", "id", "CASCADE"),
|
||||
("tag_id", "tags", "id", "CASCADE"),
|
||||
},
|
||||
"image_prompt_pairs": {
|
||||
("pair_id", "prompt_pairs", "id", "RESTRICT"),
|
||||
("image_id", "generated_images", "id", "CASCADE"),
|
||||
},
|
||||
}
|
||||
unique = {
|
||||
"prompt_pairs": ("positive_prompt_id", "negative_prompt_id"),
|
||||
"prompt_pair_tags": ("pair_id", "tag_id"),
|
||||
"image_prompt_pairs": ("pair_id", "image_id", "provenance"),
|
||||
}
|
||||
non_null = {
|
||||
"prompt_pairs": {
|
||||
"positive_prompt_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"run_count",
|
||||
"archived",
|
||||
},
|
||||
"prompt_pair_tags": {"pair_id", "tag_id"},
|
||||
"image_prompt_pairs": {"pair_id", "image_id", "provenance", "created_at"},
|
||||
}
|
||||
integer_columns = {
|
||||
"id",
|
||||
"positive_prompt_id",
|
||||
"negative_prompt_id",
|
||||
"pair_id",
|
||||
"tag_id",
|
||||
"image_id",
|
||||
"rating",
|
||||
"run_count",
|
||||
"archived",
|
||||
}
|
||||
for table, columns in required.items():
|
||||
info = {row[1]: row for row in conn.execute(f"PRAGMA table_info({table})")}
|
||||
if not columns.issubset(info):
|
||||
return f"{table} is missing required pair columns"
|
||||
if any(not info[name][3] for name in non_null[table]):
|
||||
return f"{table} has nullable required pair columns"
|
||||
if any(
|
||||
info[name][2].upper()
|
||||
!= ("INTEGER" if name in integer_columns else "TEXT")
|
||||
for name in columns
|
||||
):
|
||||
return f"{table} has invalid pair column types"
|
||||
primary = tuple(
|
||||
name
|
||||
for name in sorted(info, key=lambda name: info[name][5])
|
||||
if info[name][5]
|
||||
)
|
||||
if primary != (
|
||||
("pair_id", "tag_id") if table == "prompt_pair_tags" else ("id",)
|
||||
):
|
||||
return f"{table} has an invalid pair primary key"
|
||||
actual_fks = {
|
||||
(row[3], row[2], row[4], row[6])
|
||||
for row in conn.execute(f"PRAGMA foreign_key_list({table})")
|
||||
}
|
||||
if actual_fks != foreign_keys[table]:
|
||||
return f"{table} has invalid pair foreign key definitions"
|
||||
if conn.execute(f"PRAGMA foreign_key_check({table})").fetchone():
|
||||
return f"{table} contains foreign key violations"
|
||||
indexes = list(conn.execute(f"PRAGMA index_list({table})"))
|
||||
index_columns = {}
|
||||
for row in indexes:
|
||||
name = row[1].replace('"', '""')
|
||||
index_columns[row[1]] = tuple(
|
||||
item[2] for item in conn.execute(f'PRAGMA index_info("{name}")')
|
||||
)
|
||||
if not any(
|
||||
row[2] and not row[4] and index_columns[row[1]] == unique[table]
|
||||
for row in indexes
|
||||
):
|
||||
return f"{table} is missing pair uniqueness constraints"
|
||||
if table == "prompt_pairs":
|
||||
null_unique = False
|
||||
for row in indexes:
|
||||
if (
|
||||
row[2]
|
||||
and row[4]
|
||||
and index_columns[row[1]] == ("positive_prompt_id",)
|
||||
):
|
||||
sql = conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='index' AND name"
|
||||
"=?",
|
||||
(row[1],),
|
||||
).fetchone()[0]
|
||||
if " ".join(sql.lower().split()).endswith(
|
||||
"where negative_prompt_id is null"
|
||||
):
|
||||
null_unique = True
|
||||
if not null_unique:
|
||||
return "prompt_pairs is missing empty-negative pair uniqueness"
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM image_prompt_pairs ip "
|
||||
"JOIN prompt_pairs pp ON pp.id=ip.pair_id "
|
||||
"JOIN generated_images gi ON gi.id=ip.image_id "
|
||||
"WHERE pp.positive_prompt_id IS NOT gi.prompt_id LIMIT 1"
|
||||
).fetchone():
|
||||
return "Image pair association has a different positive prompt owner"
|
||||
for row in conn.execute("SELECT provenance FROM image_prompt_pairs"):
|
||||
error = pair_provenance_error(row[0])
|
||||
if error:
|
||||
return error
|
||||
return None
|
||||
|
||||
def _create_indexes(self, conn: sqlite3.Connection) -> None:
|
||||
"""
|
||||
@@ -772,6 +1028,15 @@ class PromptModel:
|
||||
False,
|
||||
"prompts table is missing required columns: " + ", ".join(missing),
|
||||
)
|
||||
forbidden = conn.execute(
|
||||
"SELECT type FROM sqlite_master WHERE type IN ('trigger', 'view') "
|
||||
"LIMIT 1"
|
||||
).fetchone()
|
||||
if forbidden:
|
||||
return False, f"Database contains an unsupported {forbidden[0]}"
|
||||
pair_error = PromptModel._pair_schema_error(conn)
|
||||
if pair_error:
|
||||
return False, pair_error
|
||||
return True, "ok"
|
||||
except sqlite3.Error as e:
|
||||
return False, f"Invalid SQLite database: {e}"
|
||||
@@ -814,13 +1079,11 @@ class PromptModel:
|
||||
"""
|
||||
Replace the live database with the file at src_path.
|
||||
|
||||
The current database is first backed up next to itself as
|
||||
``<db_path>.backup_<YYYYmmdd_HHMMSS>`` (WAL content included), the
|
||||
pages of ``src_path`` are copied into the live database through
|
||||
SQLite's backup API (visible to every open connection) and the schema
|
||||
migrations run on the restored content.
|
||||
|
||||
Callers should run verify_database_file(src_path) first.
|
||||
A disposable SQLite backup of the source is migrated and verified first.
|
||||
Only then is the current database backed up next to itself as
|
||||
``<db_path>.backup_<YYYYmmdd_HHMMSS>`` (WAL content included), and the
|
||||
staged pages replace the live pages through SQLite's backup API.
|
||||
Migration failures never replace live content or modify the source.
|
||||
|
||||
Args:
|
||||
src_path: SQLite file to restore
|
||||
@@ -833,17 +1096,42 @@ class PromptModel:
|
||||
RuntimeError: If the previous database could not be backed up
|
||||
OSError: If the restored file could not be copied into place
|
||||
"""
|
||||
backup_path = ""
|
||||
if os.path.exists(self.db_path):
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_path = f"{self.db_path}.backup_{timestamp}"
|
||||
if not self.backup_database(backup_path):
|
||||
raise RuntimeError("Could not back up the current database")
|
||||
self.logger.info(f"Current database backed up to: {backup_path}")
|
||||
|
||||
self._replace_contents_from(src_path)
|
||||
with PromptModel._init_lock:
|
||||
PromptModel._initialized_paths.discard(self._schema_key())
|
||||
self._ensure_database_exists()
|
||||
self.logger.info(f"Database restored from {src_path}")
|
||||
return backup_path
|
||||
ok, reason = self.verify_database_file(src_path)
|
||||
if not ok:
|
||||
raise ValueError(reason)
|
||||
with tempfile.TemporaryDirectory(prefix="prompt-manager-restore-") as directory:
|
||||
staged_path = str(Path(directory) / "staged.db")
|
||||
source = sqlite3.connect(
|
||||
Path(src_path).resolve().as_uri() + "?mode=ro", uri=True
|
||||
)
|
||||
try:
|
||||
with closing(sqlite3.connect(staged_path)) as destination:
|
||||
source.backup(destination)
|
||||
finally:
|
||||
source.close()
|
||||
staged = None
|
||||
try:
|
||||
staged = PromptModel(staged_path)
|
||||
ok, reason = self.verify_database_file(staged_path)
|
||||
if not ok:
|
||||
raise ValueError(reason)
|
||||
staged.close_all()
|
||||
backup_path = ""
|
||||
if os.path.exists(self.db_path):
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_path = f"{self.db_path}.backup_{timestamp}"
|
||||
if not self.backup_database(backup_path):
|
||||
raise RuntimeError("Could not back up the current database")
|
||||
self.logger.info(f"Current database backed up to: {backup_path}")
|
||||
self._replace_contents_from(staged_path)
|
||||
with PromptModel._init_lock:
|
||||
PromptModel._initialized_paths.add(self._schema_key())
|
||||
self.logger.info(f"Database restored from {src_path}")
|
||||
return backup_path
|
||||
finally:
|
||||
if staged is not None:
|
||||
staged.close_all()
|
||||
with PromptModel._init_lock:
|
||||
PromptModel._initialized_paths.discard(
|
||||
os.path.normcase(os.path.realpath(staged_path))
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import os
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from .models import PromptModel, normalize_image_path, utc_now_iso
|
||||
from .pairs import PairDependencyError, PairOperations
|
||||
|
||||
# Import logging system
|
||||
try:
|
||||
@@ -146,7 +147,7 @@ def _coerce_file_info(metadata: Any) -> tuple:
|
||||
)
|
||||
|
||||
|
||||
class PromptDatabase:
|
||||
class PromptDatabase(PairOperations):
|
||||
"""Database operations class for managing prompts."""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
@@ -581,7 +582,8 @@ class PromptDatabase:
|
||||
Returns:
|
||||
bool: True if deletion was successful, False otherwise
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
with self._pair_transaction() as conn:
|
||||
self._ensure_prompts_unpaired(conn, [prompt_id])
|
||||
# First delete related images to avoid foreign key constraint
|
||||
conn.execute(
|
||||
"DELETE FROM generated_images WHERE prompt_id = ?", (prompt_id,)
|
||||
@@ -597,7 +599,7 @@ class PromptDatabase:
|
||||
Returns:
|
||||
Number of prompts deleted.
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
with self._pair_transaction() as conn:
|
||||
# Get IDs first for cascade cleanup
|
||||
ids = [
|
||||
r[0]
|
||||
@@ -607,6 +609,7 @@ class PromptDatabase:
|
||||
]
|
||||
if not ids:
|
||||
return 0
|
||||
self._ensure_prompts_unpaired(conn, ids)
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
conn.execute(
|
||||
f"DELETE FROM generated_images WHERE prompt_id IN ({placeholders})",
|
||||
@@ -632,7 +635,9 @@ class PromptDatabase:
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT DISTINCT TRIM(category) as category FROM prompts WHERE "
|
||||
"SELECT TRIM(category) AS category FROM prompts WHERE "
|
||||
"category IS NOT NULL AND TRIM(category) != '' UNION "
|
||||
"SELECT TRIM(category) AS category FROM prompt_pairs WHERE "
|
||||
"category IS NOT NULL AND TRIM(category) != '' ORDER BY category"
|
||||
)
|
||||
return [row["category"] for row in cursor.fetchall()]
|
||||
@@ -699,16 +704,16 @@ class PromptDatabase:
|
||||
|
||||
def get_all_tags(self) -> List[str]:
|
||||
"""
|
||||
Get all unique tags that are in use (linked to at least one prompt).
|
||||
Get all unique tags linked to a standalone prompt or reusable pair.
|
||||
|
||||
Returns:
|
||||
Sorted list of tag names
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT DISTINCT t.name AS tag "
|
||||
"FROM prompt_tags pt "
|
||||
"JOIN tags t ON pt.tag_id = t.id "
|
||||
"SELECT t.name AS tag FROM tags t WHERE "
|
||||
"EXISTS(SELECT 1 FROM prompt_tags pt WHERE pt.tag_id=t.id) OR "
|
||||
"EXISTS(SELECT 1 FROM prompt_pair_tags pp WHERE pp.tag_id=t.id) "
|
||||
"ORDER BY t.name"
|
||||
)
|
||||
return [row["tag"] for row in cursor.fetchall()]
|
||||
@@ -784,7 +789,8 @@ class PromptDatabase:
|
||||
|
||||
Returns tags whose name starts with ``prefix`` (case-insensitive),
|
||||
counted over the prompts that carry every tag in ``with_tags``; the
|
||||
context tags themselves are left out. Ordered by count, then name.
|
||||
context tags themselves are left out. Counts include separate standalone
|
||||
and pair references, with owner types kept distinct. Ordered by count/name.
|
||||
"""
|
||||
seen = set()
|
||||
context = []
|
||||
@@ -799,11 +805,11 @@ class PromptDatabase:
|
||||
if context:
|
||||
placeholders = ",".join("?" * len(context))
|
||||
where.append(
|
||||
"pt.prompt_id IN ("
|
||||
" SELECT pt2.prompt_id FROM prompt_tags pt2"
|
||||
"(pt.kind,pt.owner_id) IN ("
|
||||
" SELECT pt2.kind,pt2.owner_id FROM tag_refs pt2"
|
||||
" JOIN tags t2 ON t2.id = pt2.tag_id"
|
||||
f" WHERE t2.name COLLATE NOCASE IN ({placeholders})"
|
||||
" GROUP BY pt2.prompt_id"
|
||||
" GROUP BY pt2.kind,pt2.owner_id"
|
||||
" HAVING COUNT(DISTINCT lower(t2.name)) = ?)"
|
||||
)
|
||||
params.extend(context)
|
||||
@@ -813,8 +819,11 @@ class PromptDatabase:
|
||||
params.append(max(1, int(limit)))
|
||||
|
||||
sql = (
|
||||
"WITH tag_refs(kind,owner_id,tag_id) AS ("
|
||||
"SELECT 'prompt',prompt_id,tag_id FROM prompt_tags UNION ALL "
|
||||
"SELECT 'pair',pair_id,tag_id FROM prompt_pair_tags) "
|
||||
"SELECT t.name AS name, COUNT(*) AS count"
|
||||
" FROM prompt_tags pt JOIN tags t ON t.id = pt.tag_id"
|
||||
" FROM tag_refs pt JOIN tags t ON t.id = pt.tag_id"
|
||||
f" WHERE {' AND '.join(where)}"
|
||||
" GROUP BY t.id"
|
||||
" ORDER BY count DESC, t.name COLLATE NOCASE ASC"
|
||||
@@ -921,6 +930,8 @@ class PromptDatabase:
|
||||
|
||||
old_name = old_name.strip()
|
||||
new_name = new_name.strip()
|
||||
if old_name == new_name:
|
||||
return {"success": True, "affected_count": 0, "skipped_count": 0}
|
||||
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute("SELECT id FROM tags WHERE name = ?", (old_name,))
|
||||
@@ -942,12 +953,14 @@ class PromptDatabase:
|
||||
if existing_new:
|
||||
# Target tag exists — merge: move links, handle conflicts, delete old
|
||||
new_tag_id = existing_new["id"]
|
||||
conn.execute(
|
||||
"UPDATE OR IGNORE prompt_tags SET tag_id = ? WHERE tag_id = ?",
|
||||
(new_tag_id, old_tag_id),
|
||||
)
|
||||
# Delete remaining links (conflicts = prompt already had the target tag)
|
||||
conn.execute("DELETE FROM prompt_tags WHERE tag_id = ?", (old_tag_id,))
|
||||
for junction in ("prompt_tags", "prompt_pair_tags"):
|
||||
conn.execute(
|
||||
f"UPDATE OR IGNORE {junction} SET tag_id = ? WHERE tag_id = ?",
|
||||
(new_tag_id, old_tag_id),
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM {junction} WHERE tag_id = ?", (old_tag_id,)
|
||||
)
|
||||
conn.execute("DELETE FROM tags WHERE id = ?", (old_tag_id,))
|
||||
else:
|
||||
# Simple rename
|
||||
@@ -966,7 +979,7 @@ class PromptDatabase:
|
||||
"""
|
||||
Remove a tag from all prompts that use it.
|
||||
|
||||
With junction tables, deleting from the tags table cascades to prompt_tags.
|
||||
Deleting from tags cascades to standalone and pair tag relationships.
|
||||
|
||||
Args:
|
||||
tag_name: Tag name to remove
|
||||
@@ -1033,20 +1046,27 @@ class PromptDatabase:
|
||||
if not src_row:
|
||||
continue
|
||||
src_id = src_row["id"]
|
||||
if src_id == target_id:
|
||||
continue
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT COUNT(*) as c FROM prompt_tags WHERE tag_id = ?", (src_id,)
|
||||
)
|
||||
src_count = cursor.fetchone()["c"]
|
||||
|
||||
if src_count > 0:
|
||||
# Move links from source to target (ignore conflicts)
|
||||
conn.execute(
|
||||
"UPDATE OR IGNORE prompt_tags SET tag_id = ? WHERE tag_id = ?",
|
||||
(target_id, src_id),
|
||||
)
|
||||
# Delete remaining (conflicts = prompt already had target tag)
|
||||
conn.execute("DELETE FROM prompt_tags WHERE tag_id = ?", (src_id,))
|
||||
pair_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM prompt_pair_tags WHERE tag_id = ?", (src_id,)
|
||||
).fetchone()[0]
|
||||
if src_count or pair_count:
|
||||
for junction in ("prompt_tags", "prompt_pair_tags"):
|
||||
conn.execute(
|
||||
f"UPDATE OR IGNORE {junction} "
|
||||
"SET tag_id = ? WHERE tag_id = ?",
|
||||
(target_id, src_id),
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM {junction} WHERE tag_id = ?", (src_id,)
|
||||
)
|
||||
tags_merged += 1
|
||||
affected += src_count
|
||||
|
||||
@@ -1290,7 +1310,7 @@ class PromptDatabase:
|
||||
"""
|
||||
self.logger.info("Starting duplicate cleanup process with image preservation")
|
||||
try:
|
||||
with self.model.get_connection() as conn:
|
||||
with self._pair_transaction() as conn:
|
||||
# Find duplicates by text content (case-insensitive)
|
||||
# Note: Removed ORDER BY from GROUP_CONCAT for SQLite compatibility
|
||||
# We'll sort the IDs manually after fetching
|
||||
@@ -1319,6 +1339,11 @@ class PromptDatabase:
|
||||
id_date_pairs.sort(key=lambda x: x[1]) # Sort by date
|
||||
sorted_ids = [int(pair[0]) for pair in id_date_pairs]
|
||||
|
||||
try:
|
||||
self._ensure_prompts_unpaired(conn, sorted_ids)
|
||||
except PairDependencyError:
|
||||
continue
|
||||
|
||||
# Keep the oldest one (first), merge and delete the rest
|
||||
primary_id = sorted_ids[0] # Keep the oldest
|
||||
duplicate_ids = sorted_ids[1:]
|
||||
@@ -1864,7 +1889,8 @@ class PromptDatabase:
|
||||
Raises:
|
||||
sqlite3.IntegrityError: Another prompt already has the new hash.
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
with self._pair_transaction() as conn:
|
||||
self._ensure_prompts_unpaired(conn, [prompt_id])
|
||||
cursor = conn.execute(
|
||||
"UPDATE prompts SET text = ?, hash = ?, updated_at = ? WHERE id = ?",
|
||||
(
|
||||
@@ -1912,7 +1938,8 @@ class PromptDatabase:
|
||||
Number of prompts actually deleted.
|
||||
"""
|
||||
count = 0
|
||||
with self.model.get_connection() as conn:
|
||||
with self._pair_transaction() as conn:
|
||||
self._ensure_prompts_unpaired(conn, prompt_ids)
|
||||
for pid in prompt_ids:
|
||||
conn.execute("DELETE FROM generated_images WHERE prompt_id = ?", (pid,))
|
||||
cursor = conn.execute("DELETE FROM prompts WHERE id = ?", (pid,))
|
||||
@@ -1985,7 +2012,7 @@ class PromptDatabase:
|
||||
cursor = conn.execute(
|
||||
"""SELECT gi.prompt_id, p.text, p.category, p.rating, p.notes,
|
||||
gi.workflow_data, gi.prompt_metadata, gi.generation_time,
|
||||
gi.parameters
|
||||
gi.parameters, gi.id, gi.image_path, gi.file_path
|
||||
FROM generated_images gi
|
||||
JOIN prompts p ON gi.prompt_id = p.id
|
||||
WHERE gi.image_path = ? OR gi.file_path = ?
|
||||
@@ -1997,9 +2024,71 @@ class PromptDatabase:
|
||||
f"%{escape_like(os.path.basename(image_path))}",
|
||||
),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
rows = cursor.fetchall()
|
||||
exact = [
|
||||
row
|
||||
for row in rows
|
||||
if row[10] == image_path or row[11] == normalize_image_path(image_path)
|
||||
]
|
||||
matches = exact or rows
|
||||
if len(matches) != 1:
|
||||
return None
|
||||
row = matches[0]
|
||||
associations = self.get_image_prompt_pairs(row[9])
|
||||
pair_ids = {item["pair_id"] for item in associations}
|
||||
pair = (
|
||||
self.get_prompt_pair(next(iter(pair_ids)))
|
||||
if len(pair_ids) == 1
|
||||
else None
|
||||
)
|
||||
candidates = []
|
||||
for item in associations:
|
||||
evidence = item["provenance"]
|
||||
positive_side = evidence.get("positive")
|
||||
negative_side = evidence.get("negative")
|
||||
if not isinstance(positive_side, dict) or not isinstance(
|
||||
negative_side, dict
|
||||
):
|
||||
continue
|
||||
positive, negative = positive_side.get("text"), negative_side.get(
|
||||
"text"
|
||||
)
|
||||
if not isinstance(positive, str) or not isinstance(negative, str):
|
||||
continue
|
||||
sources = []
|
||||
for side in (positive_side, negative_side):
|
||||
provenance = side.get("provenance")
|
||||
sources.append(
|
||||
provenance.get("text_source")
|
||||
if isinstance(provenance, dict)
|
||||
else None
|
||||
)
|
||||
score = sources.count("execution")
|
||||
source = (
|
||||
"execution"
|
||||
if score == 2
|
||||
else (
|
||||
"mixed"
|
||||
if score
|
||||
else (
|
||||
"embedded-parameters"
|
||||
if sources == ["embedded-parameters"] * 2
|
||||
else "graph-inferred"
|
||||
)
|
||||
)
|
||||
)
|
||||
candidates.append((score, positive, negative, source))
|
||||
context = None
|
||||
if candidates and pair:
|
||||
best_score = max(item[0] for item in candidates)
|
||||
contexts = {item[1:] for item in candidates if item[0] == best_score}
|
||||
if len(contexts) == 1:
|
||||
positive, negative, source = next(iter(contexts))
|
||||
context = {
|
||||
"positive_text": positive,
|
||||
"negative_text": negative,
|
||||
"text_source": source,
|
||||
}
|
||||
tags = self._get_prompt_tags(conn, row[0])
|
||||
try:
|
||||
workflow = json.loads(row[5]) if row[5] else None
|
||||
@@ -2010,6 +2099,8 @@ class PromptDatabase:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
prompt_meta = None
|
||||
return {
|
||||
"pair": pair,
|
||||
"pair_context": context,
|
||||
"prompt_id": row[0],
|
||||
"text": row[1],
|
||||
"category": row[2],
|
||||
@@ -2050,11 +2141,15 @@ class PromptDatabase:
|
||||
|
||||
Returns number of prompts removed.
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
with self._pair_transaction() as conn:
|
||||
cursor = conn.execute("""
|
||||
SELECT p.id FROM prompts p
|
||||
LEFT JOIN generated_images gi ON p.id = gi.prompt_id
|
||||
WHERE gi.prompt_id IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM prompt_pairs pp
|
||||
WHERE pp.positive_prompt_id = p.id OR pp.negative_prompt_id = p.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM prompt_tags pt
|
||||
JOIN tags t ON pt.tag_id = t.id
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Atomic metadata enrichment for a single known generation context."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from .models import normalize_image_path
|
||||
from .operations import _coerce_file_info
|
||||
|
||||
try:
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
def ingest_resolved_pair(
|
||||
db: Any, image_path: str, metadata: Dict[str, Any], resolution: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
state = resolution.get("state", "unresolved")
|
||||
if state != "resolved":
|
||||
return {"status": state, "pairs_added": 0, "links_added": 0}
|
||||
positive, negative = resolution["positive"], resolution["negative"]
|
||||
positive_text = positive.get("storage_text", positive["text"])
|
||||
negative_text = negative.get("storage_text", negative["text"])
|
||||
normalized = normalize_image_path(image_path)
|
||||
provenance = {"resolver_version": 1, "positive": positive, "negative": negative}
|
||||
encoded = json.dumps(
|
||||
provenance, sort_keys=True, separators=(",", ":"), allow_nan=False
|
||||
)
|
||||
with db._pair_transaction() as conn:
|
||||
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 {"status": "conflict", "pairs_added": 0, "links_added": 0}
|
||||
existing = images[0] if images else None
|
||||
if existing:
|
||||
prior = conn.execute(
|
||||
"SELECT n.hash AS negative_hash FROM image_prompt_pairs ip "
|
||||
"JOIN prompt_pairs pp ON pp.id=ip.pair_id "
|
||||
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE ip.image_id=?",
|
||||
(existing["id"],),
|
||||
).fetchall()
|
||||
expected = (
|
||||
generate_prompt_hash(negative_text) if negative_text.strip() else None
|
||||
)
|
||||
if any(row["negative_hash"] != expected for row in prior):
|
||||
return {"status": "conflict", "pairs_added": 0, "links_added": 0}
|
||||
prior_pair = conn.execute(
|
||||
"SELECT pp.id FROM prompt_pairs pp "
|
||||
"JOIN prompts p ON p.id=pp.positive_prompt_id "
|
||||
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id "
|
||||
"WHERE p.hash=? AND n.hash IS ?",
|
||||
(
|
||||
generate_prompt_hash(positive_text),
|
||||
generate_prompt_hash(negative_text) if negative_text.strip() else None,
|
||||
),
|
||||
).fetchone()
|
||||
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)
|
||||
snapshots = {
|
||||
column: json.dumps(metadata[key], sort_keys=True, allow_nan=False)
|
||||
for column, key in (
|
||||
("workflow_data", "workflow"),
|
||||
("prompt_metadata", "prompt"),
|
||||
("parameters", "parameters"),
|
||||
)
|
||||
if key in metadata
|
||||
}
|
||||
info = {
|
||||
"file_size": file_size,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"format": image_format,
|
||||
}
|
||||
snapshots.update(
|
||||
{key: value for key, value in info.items() if value is not None}
|
||||
)
|
||||
changed = False
|
||||
if existing:
|
||||
image_id = existing["id"]
|
||||
updates = {
|
||||
key: value for key, value in snapshots.items() if existing[key] != value
|
||||
}
|
||||
if updates:
|
||||
conn.execute(
|
||||
"UPDATE generated_images SET "
|
||||
+ ",".join(f"{key}=?" for key in updates)
|
||||
+ " WHERE id=?",
|
||||
[*updates.values(), image_id],
|
||||
)
|
||||
changed = True
|
||||
else:
|
||||
columns = ["prompt_id", "image_path", "filename", "file_path", *snapshots]
|
||||
values = [
|
||||
pair["positive_prompt_id"],
|
||||
image_path,
|
||||
os.path.basename(image_path),
|
||||
normalized,
|
||||
*snapshots.values(),
|
||||
]
|
||||
image_id = conn.execute(
|
||||
"INSERT INTO generated_images("
|
||||
+ ",".join(columns)
|
||||
+ ") VALUES("
|
||||
+ ",".join("?" for _ in columns)
|
||||
+ ")",
|
||||
values,
|
||||
).lastrowid
|
||||
linked = conn.execute(
|
||||
"SELECT 1 FROM image_prompt_pairs WHERE pair_id=? "
|
||||
"AND image_id=? AND provenance=?",
|
||||
(pair_id, image_id, encoded),
|
||||
).fetchone()
|
||||
db._link_image_to_pair(conn, pair_id, image_id, provenance)
|
||||
status = (
|
||||
"added"
|
||||
if existing is None
|
||||
else "enriched" if changed or not linked else "unchanged"
|
||||
)
|
||||
return {
|
||||
"status": status,
|
||||
"pairs_added": pairs_added,
|
||||
"links_added": int(not linked),
|
||||
"pair_id": pair_id,
|
||||
"image_id": image_id,
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Ordered reusable prompt pairs and their generation provenance."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
from .models import pair_provenance_error, utc_now_iso
|
||||
|
||||
try:
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class PairDependencyError(ValueError):
|
||||
"""An operation would change or remove content used by a saved pair."""
|
||||
|
||||
|
||||
def _validate_metadata(
|
||||
title: Optional[str],
|
||||
category: Optional[str],
|
||||
tags: Optional[List[str]],
|
||||
rating: Optional[int],
|
||||
notes: Optional[str],
|
||||
) -> Optional[List[str]]:
|
||||
for name, value in (("title", title), ("category", category), ("notes", notes)):
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ValueError(f"{name} must be a string")
|
||||
if rating is not None and (type(rating) is not int or not 1 <= rating <= 5):
|
||||
raise ValueError("Rating must be between 1 and 5")
|
||||
if tags is None:
|
||||
return None
|
||||
if not isinstance(tags, list) or any(not isinstance(tag, str) for tag in tags):
|
||||
raise ValueError("tags must be a list of strings")
|
||||
return list(dict.fromkeys(tag.strip() for tag in tags if tag.strip()))
|
||||
|
||||
|
||||
class PairOperations:
|
||||
"""Connection-level pair operations mixed into PromptDatabase."""
|
||||
|
||||
@contextmanager
|
||||
def _pair_transaction(self) -> Iterator[sqlite3.Connection]:
|
||||
conn = self.model.get_connection()
|
||||
if conn.in_transaction:
|
||||
raise RuntimeError("Pair operations require their own transaction")
|
||||
with conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
yield conn
|
||||
|
||||
def _ensure_prompts_unpaired(
|
||||
self, conn: sqlite3.Connection, prompt_ids: List[int]
|
||||
) -> None:
|
||||
if not prompt_ids:
|
||||
return
|
||||
placeholders = ",".join("?" for _ in prompt_ids)
|
||||
row = conn.execute(
|
||||
f"SELECT id FROM prompt_pairs WHERE positive_prompt_id IN ({placeholders}) "
|
||||
f"OR negative_prompt_id IN ({placeholders}) LIMIT 1",
|
||||
[*prompt_ids, *prompt_ids],
|
||||
).fetchone()
|
||||
if row:
|
||||
raise PairDependencyError(
|
||||
f"Prompt belongs to saved pair {row['id']}; delete the pair first "
|
||||
"or save changed text as a new prompt"
|
||||
)
|
||||
|
||||
def _pair_component(self, conn: sqlite3.Connection, text: str) -> int:
|
||||
prompt_hash = generate_prompt_hash(text)
|
||||
conn.execute(
|
||||
"INSERT INTO prompts(text, hash, created_at, updated_at) VALUES(?,?,?,?) "
|
||||
"ON CONFLICT(hash) DO NOTHING",
|
||||
(text.strip(), prompt_hash, utc_now_iso(), utc_now_iso()),
|
||||
)
|
||||
return conn.execute(
|
||||
"SELECT id FROM prompts WHERE hash = ?", (prompt_hash,)
|
||||
).fetchone()["id"]
|
||||
|
||||
def _sync_pair_tags(
|
||||
self, conn: sqlite3.Connection, pair_id: int, tags: List[str]
|
||||
) -> None:
|
||||
conn.execute("DELETE FROM prompt_pair_tags WHERE pair_id = ?", (pair_id,))
|
||||
for tag_id in self._ensure_tags(conn, tags).values():
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pair_tags(pair_id,tag_id) VALUES(?,?)",
|
||||
(pair_id, tag_id),
|
||||
)
|
||||
|
||||
def save_prompt_pair(
|
||||
self,
|
||||
positive_text: str,
|
||||
negative_text: str = "",
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
rating: Optional[int] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
|
||||
with self._pair_transaction() as conn:
|
||||
return self._save_pair(
|
||||
conn,
|
||||
positive_text,
|
||||
negative_text,
|
||||
title=title,
|
||||
category=category,
|
||||
tags=tags,
|
||||
rating=rating,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
def _save_pair(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
positive_text: str,
|
||||
negative_text: str = "",
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
rating: Optional[int] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Save an ordered pair atomically; repeat saves preserve its metadata."""
|
||||
if not isinstance(positive_text, str) or not positive_text.strip():
|
||||
raise ValueError("Positive prompt text cannot be empty")
|
||||
if not isinstance(negative_text, str):
|
||||
raise ValueError("Negative prompt text must be a string; use '' for empty")
|
||||
tags = _validate_metadata(title, category, tags, rating, notes)
|
||||
positive = self._pair_component(conn, positive_text)
|
||||
negative = (
|
||||
self._pair_component(conn, negative_text) if negative_text.strip() else None
|
||||
)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM prompt_pairs WHERE positive_prompt_id = ? "
|
||||
"AND negative_prompt_id IS ?",
|
||||
(positive, negative),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return existing["id"]
|
||||
now = utc_now_iso()
|
||||
pair_id = conn.execute(
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id,title,"
|
||||
"category,rating,notes,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)",
|
||||
(positive, negative, title, category, rating, notes, now, now),
|
||||
).lastrowid
|
||||
if tags:
|
||||
self._sync_pair_tags(conn, pair_id, tags)
|
||||
return pair_id
|
||||
|
||||
def _read_pair_rows(
|
||||
self, conn: sqlite3.Connection, where: str, params: List[Any], suffix: str = ""
|
||||
) -> List[Dict[str, Any]]:
|
||||
rows = conn.execute(
|
||||
"SELECT pp.*, p.text AS positive_text, "
|
||||
"COALESCE(n.text,'') AS negative_text,"
|
||||
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
|
||||
"WHERE ip.pair_id=pp.id) AS image_count FROM prompt_pairs pp "
|
||||
"JOIN prompts p ON p.id=pp.positive_prompt_id "
|
||||
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE " + where + suffix,
|
||||
params,
|
||||
).fetchall()
|
||||
pairs = [dict(row) for row in rows]
|
||||
if not pairs:
|
||||
return pairs
|
||||
by_id = {pair["id"]: pair for pair in pairs}
|
||||
for pair in pairs:
|
||||
pair["archived"] = bool(pair["archived"])
|
||||
pair["tags"] = []
|
||||
placeholders = ",".join("?" for _ in by_id)
|
||||
for row in conn.execute(
|
||||
"SELECT pt.pair_id,t.name FROM prompt_pair_tags pt JOIN tags t ON "
|
||||
f"t.id=pt.tag_id WHERE pt.pair_id IN ({placeholders}) ORDER BY t.name",
|
||||
list(by_id),
|
||||
):
|
||||
by_id[row["pair_id"]]["tags"].append(row["name"])
|
||||
return pairs
|
||||
|
||||
def get_prompt_pair(self, pair_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Return components, pair metadata and image count, including archives."""
|
||||
rows = self._read_pair_rows(self.model.get_connection(), "pp.id = ?", [pair_id])
|
||||
return rows[0] if rows else None
|
||||
|
||||
def list_prompt_pairs(
|
||||
self,
|
||||
*,
|
||||
text: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
include_archived: bool = False,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
sort: str = "newest",
|
||||
) -> Dict[str, Any]:
|
||||
"""List pairs with all-tag filtering and stable paginated ordering."""
|
||||
orders = {
|
||||
"newest": "pp.id DESC",
|
||||
"last_used_desc": "julianday(pp.last_used_at) DESC, pp.id DESC",
|
||||
}
|
||||
if sort not in orders:
|
||||
raise ValueError("Unknown pair sort order")
|
||||
if type(limit) is not int or not 1 <= limit <= 500:
|
||||
raise ValueError("limit must be between 1 and 500")
|
||||
if type(offset) is not int or offset < 0:
|
||||
raise ValueError("offset must be a nonnegative integer")
|
||||
tags = _validate_metadata(None, category, tags, None, None)
|
||||
if text is not None and not isinstance(text, str):
|
||||
raise ValueError("text must be a string")
|
||||
clauses, params = ["1=1"], []
|
||||
if not include_archived:
|
||||
clauses.append("pp.archived=0")
|
||||
if category is not None:
|
||||
clauses.append("pp.category = ?")
|
||||
params.append(category)
|
||||
if text:
|
||||
clauses.append("(p.text LIKE ? OR n.text LIKE ? OR pp.title LIKE ?)")
|
||||
params.extend([f"%{text}%"] * 3)
|
||||
for tag in tags or []:
|
||||
clauses.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=?)"
|
||||
)
|
||||
params.append(tag)
|
||||
where = " AND ".join(clauses)
|
||||
conn = self.model.get_connection()
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM prompt_pairs pp "
|
||||
"JOIN prompts p ON p.id=pp.positive_prompt_id "
|
||||
"LEFT JOIN prompts n ON n.id=pp.negative_prompt_id WHERE " + where,
|
||||
params,
|
||||
).fetchone()[0]
|
||||
pairs = self._read_pair_rows(
|
||||
conn,
|
||||
where,
|
||||
[*params, limit, offset],
|
||||
f" ORDER BY {orders[sort]} LIMIT ? OFFSET ?",
|
||||
)
|
||||
return {
|
||||
"pairs": pairs,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": offset + len(pairs) < total,
|
||||
}
|
||||
|
||||
def update_prompt_pair_metadata(
|
||||
self,
|
||||
pair_id: int,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
rating: Optional[int] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Update pair metadata explicitly; None preserves and empty values clear."""
|
||||
tags = _validate_metadata(title, category, tags, rating, notes)
|
||||
changes = {
|
||||
key: value
|
||||
for key, value in (
|
||||
("title", title),
|
||||
("category", category),
|
||||
("rating", rating),
|
||||
("notes", notes),
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
if not changes and tags is None:
|
||||
return False
|
||||
with self._pair_transaction() as conn:
|
||||
if not conn.execute(
|
||||
"SELECT 1 FROM prompt_pairs WHERE id=?", (pair_id,)
|
||||
).fetchone():
|
||||
return False
|
||||
changes["updated_at"] = utc_now_iso()
|
||||
conn.execute(
|
||||
"UPDATE prompt_pairs SET "
|
||||
+ ",".join(f"{key}=?" for key in changes)
|
||||
+ " WHERE id=?",
|
||||
[*changes.values(), pair_id],
|
||||
)
|
||||
if tags is not None:
|
||||
self._sync_pair_tags(conn, pair_id, tags)
|
||||
return True
|
||||
|
||||
def archive_prompt_pair(self, pair_id: int, archived: bool = True) -> bool:
|
||||
"""Hide a reusable pair without changing components or generation history."""
|
||||
if type(archived) is not bool:
|
||||
raise ValueError("archived must be a boolean")
|
||||
with self._pair_transaction() as conn:
|
||||
return (
|
||||
conn.execute(
|
||||
"UPDATE prompt_pairs SET archived=?,updated_at=? WHERE id=?",
|
||||
(int(archived), utc_now_iso(), pair_id),
|
||||
).rowcount
|
||||
> 0
|
||||
)
|
||||
|
||||
def delete_prompt_pair(self, pair_id: int) -> bool:
|
||||
"""Delete unused pair metadata; generated pairs must be archived instead."""
|
||||
with self._pair_transaction() as conn:
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM image_prompt_pairs WHERE pair_id=? LIMIT 1", (pair_id,)
|
||||
).fetchone():
|
||||
raise PairDependencyError(
|
||||
"Pair has generated images; archive it instead"
|
||||
)
|
||||
return (
|
||||
conn.execute("DELETE FROM prompt_pairs WHERE id=?", (pair_id,)).rowcount
|
||||
> 0
|
||||
)
|
||||
|
||||
def record_prompt_pair_use(self, pair_id: int, times: int = 1) -> bool:
|
||||
"""Count pair uses independently of standalone prompt usage."""
|
||||
if type(times) is not int or times < 1:
|
||||
raise ValueError("times must be a positive integer")
|
||||
with self._pair_transaction() as conn:
|
||||
return (
|
||||
conn.execute(
|
||||
"UPDATE prompt_pairs SET run_count=run_count+?,last_used_at=? "
|
||||
"WHERE id=?",
|
||||
(times, utc_now_iso(), pair_id),
|
||||
).rowcount
|
||||
> 0
|
||||
)
|
||||
|
||||
def link_image_to_pair(
|
||||
self, pair_id: int, image_id: int, provenance: Dict[str, Any]
|
||||
) -> int:
|
||||
"""Associate an image with immutable run provenance, idempotently."""
|
||||
with self._pair_transaction() as conn:
|
||||
return self._link_image_to_pair(conn, pair_id, image_id, provenance)
|
||||
|
||||
def _link_image_to_pair(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
pair_id: int,
|
||||
image_id: int,
|
||||
provenance: Dict[str, Any],
|
||||
) -> int:
|
||||
"""Associate an image with immutable run provenance, idempotently."""
|
||||
if not isinstance(provenance, dict):
|
||||
raise ValueError("provenance must be an object")
|
||||
try:
|
||||
encoded = json.dumps(
|
||||
provenance, sort_keys=True, separators=(",", ":"), allow_nan=False
|
||||
)
|
||||
except (TypeError, ValueError, RecursionError) as error:
|
||||
raise ValueError("provenance must contain JSON values") from error
|
||||
error = pair_provenance_error(encoded)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
pair = conn.execute(
|
||||
"SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", (pair_id,)
|
||||
).fetchone()
|
||||
image = conn.execute(
|
||||
"SELECT prompt_id FROM generated_images WHERE id=?", (image_id,)
|
||||
).fetchone()
|
||||
if not pair or not image:
|
||||
raise ValueError("Pair and image must exist")
|
||||
if pair["positive_prompt_id"] != image["prompt_id"]:
|
||||
raise ValueError("Image must belong to the pair's positive prompt")
|
||||
conn.execute(
|
||||
"INSERT INTO image_prompt_pairs(pair_id,image_id,provenance,created_at) "
|
||||
"VALUES(?,?,?,?) ON CONFLICT(pair_id,image_id,provenance) DO NOTHING",
|
||||
(pair_id, image_id, encoded, utc_now_iso()),
|
||||
)
|
||||
return conn.execute(
|
||||
"SELECT id FROM image_prompt_pairs "
|
||||
"WHERE pair_id=? AND image_id=? AND provenance=?",
|
||||
(pair_id, image_id, encoded),
|
||||
).fetchone()["id"]
|
||||
|
||||
def get_image_prompt_pairs(self, image_id: int) -> List[Dict[str, Any]]:
|
||||
"""Return every recorded pair association with decoded provenance."""
|
||||
rows = (
|
||||
self.model.get_connection()
|
||||
.execute(
|
||||
"SELECT * FROM image_prompt_pairs WHERE image_id=? ORDER BY id",
|
||||
(image_id,),
|
||||
)
|
||||
.fetchall()
|
||||
)
|
||||
return [
|
||||
{**dict(row), "provenance": json.loads(row["provenance"])} for row in rows
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
# Application Redesign Development Plan
|
||||
|
||||
The maintainer approved the [interactive concept](concepts/workspace/README.md)
|
||||
on 2026-10-17. It is a design reference with simulated operations, not production
|
||||
code. This plan proposes implementation order and acceptance gates.
|
||||
|
||||
## Scope and compatibility
|
||||
|
||||
Rebuild the standalone application around one shared shell. Preserve existing
|
||||
node schemas, saved workflows, backend capabilities, pinned Viewer.js and the
|
||||
ComfyAssets toolkit. Keep existing application entry points usable during the
|
||||
transition. Do not change the running maintainer instance during automated tests.
|
||||
|
||||
Use the concept's feature inventory as a parity checklist. Each row needs an
|
||||
implementation issue, observable acceptance criteria and automated test coverage.
|
||||
Record any intentional behavior change explicitly; no silent feature removal.
|
||||
|
||||
## Proposed delivery sequence
|
||||
|
||||
Use a tracking issue and small issue-linked branches and PRs. Dependencies may
|
||||
require splitting these milestones further.
|
||||
|
||||
| Milestone | Deliverable and acceptance focus |
|
||||
| --- | --- |
|
||||
| 1. Foundation | Production shell, routing, shared theme and disposable page-test harness. Verify startup, navigation, dark/light changes, cleanup and existing entry points. Establish discovered-test and coverage baselines before changes. |
|
||||
| 2. Image workspace | Real API-backed filters, Grid/List/Masonry, readable thumbnails, selection and shared Viewer.js inspector. Continuous infinite scroll only, with accessible Load more fallback, stable ordering, retry and restored browsing context. Bound retained DOM and test large datasets. |
|
||||
| 3. Prompt library and history | Real positive/negative associations, selected full negative, run history and shared image filtering. Design any missing execution-history persistence and migration before wiring the timeline; never invent historical runs from incomplete metadata. |
|
||||
| 4. Send to ComfyUI | Shared paper-plane actions wherever prompt text appears; role-specific and pair sends, destination preview, acknowledgement and failure recovery. Cover explicit empty versus unknown negatives and connected inputs. Never queue or replace a workflow. |
|
||||
| 5. Editing and review | Prompt editing, vocabulary management, per-image manual/auto-tag approval and Needs review. Preserve cancellation semantics and distinguish tag approval from prompt-association decisions. |
|
||||
| 6. System and maintenance | Settings, sources/scans, integrations, jobs, diagnostics, backup/restore and maintenance impact previews. Cover existing capabilities before retiring their old controls. |
|
||||
| 7. Additional organization | Favorites independent of ratings, collections, saved views, Activity, scheduled backups and portable import previews. Specify persistence, validation and recovery separately for each capability. |
|
||||
| 8. Cutover | Complete the parity checklist, migration and upgrade validation, accessibility/performance checks, documentation and targeted maintainer live testing. Switch the default only after these pass; preserve compatible URLs. |
|
||||
|
||||
## TDD and test layers
|
||||
|
||||
For each behavior, first write and run a meaningful failing test. Implement the
|
||||
smallest working change, verify it passes, then refactor with the suite green.
|
||||
Include failing-before/passing-after evidence in the PR.
|
||||
|
||||
- **Unit:** filtering and selection state, cursor handling, prompt roles,
|
||||
formatting, validation, navigation and lifecycle cleanup.
|
||||
- **Integration:** real HTTP handlers and disposable SQLite/filesystem fixtures;
|
||||
persistence, migration from supported old schemas, idempotent scans, restore,
|
||||
rollback and reference-safe deletion. Mock inference and external services.
|
||||
- **Smoke:** shipped production pages start and navigate with real fixture APIs;
|
||||
test populated, empty and error states, assets and unexpected console failures.
|
||||
- **Browser workflows:** production HTML/JS across Chromium, Firefox, WebKit and
|
||||
touch; editing/reload, infinite append, viewer cleanup, tagging approval,
|
||||
keyboard access, delayed responses, cancellation and retries.
|
||||
- **Host integration:** pinned disposable CPU ComfyUI, Classic and Nodes 2.0,
|
||||
switching both ways and workflow reload for host-facing changes. Exercise
|
||||
independent and combined pack installations for shared bridge/toolkit changes.
|
||||
|
||||
Retain existing test discovery and coverage gates. Carry forward database
|
||||
injection and open-handle checks into new fixtures. Add new frontend modules to
|
||||
coverage measurement and its baseline; do not lower floors or replace existing
|
||||
regression coverage with mockup-only tests.
|
||||
|
||||
## GitHub and release gates
|
||||
|
||||
Extend existing required workflows: the Python 3.10–3.13 matrix on Linux,
|
||||
Windows and macOS; 85% Python coverage; formatting; frontend units and per-file
|
||||
coverage floors; vendor checks; browser workflows; real ComfyUI compatibility.
|
||||
Publish traces, screenshots, logs and coverage artifacts on failures. See
|
||||
[TESTING.md](../TESTING.md) for commands and fixture isolation requirements.
|
||||
|
||||
Every PR must map acceptance criteria to tests, report relevant checks and include
|
||||
screenshots for visual changes. All required checks must pass before merging.
|
||||
Use maintainer live review at meaningful visual and host-integration milestones;
|
||||
capture discovered regressions in automated tests. Notify the maintainer when
|
||||
Python changes require a ComfyUI restart.
|
||||
|
||||
Keep version/publication changes separate from intermediate UI work: the existing
|
||||
registry workflow publishes on main/master changes to `pyproject.toml` or manual
|
||||
dispatch. Do not trigger a release merely to preview a redesign milestone.
|
||||
@@ -0,0 +1,136 @@
|
||||
# Prompt Manager application redesign concept
|
||||
|
||||
Status: visual concept approved by the maintainer, 2026-10-17. Production
|
||||
implementation is pending; see the [development plan](../../application-redesign.md).
|
||||
|
||||
## Review the mockup
|
||||
|
||||
Run `python tools/preview-prompt-timeline.py --bind 0.0.0.0 --port 8767` and
|
||||
open `/workspace/`. The original approved timeline remains available at `/`.
|
||||
The server exposes an explicit allowlist of concept files and pinned assets.
|
||||
All examples are synthetic; there are no application API calls, database access,
|
||||
real model downloads, or ComfyUI handoffs. Local file inspection previews an
|
||||
image in the browser without uploading it. Exports contain sample data only.
|
||||
|
||||
The sidebar opens Images, Prompt library, Tags & categories, Inspect a file,
|
||||
Activity, System and Design review. System includes Overview, Appearance,
|
||||
Sources & scans, Integrations, Auto-tagging, Maintenance, Backup & export, and
|
||||
Diagnostics & logs. The in-app Design review contains the feature map below.
|
||||
|
||||
## Proposed direction
|
||||
|
||||
One application shell replaces separate Admin, Gallery and Metadata shells.
|
||||
Use the approved shared ComfyAssets theme: ComfyUI dark/light, blue primary
|
||||
actions, green positive roles, red negative roles and errors. Preserve existing
|
||||
capabilities and services while consolidating controls and clarifying their scope.
|
||||
|
||||
Browsing is image-led with readable thumbnails and one metadata inspector.
|
||||
Prompt library groups by positive; negative variants and runs share its gallery.
|
||||
The full selected negative appears directly beneath the positive in history.
|
||||
Favorites express preference independently of the existing quality rating.
|
||||
Collections organize projects without duplicating recipe text; saved views keep
|
||||
search/filter context. New capabilities are proposals, not implemented features.
|
||||
|
||||
Maintenance moves out of the browsing toolbar. Checks, impact preview, backups,
|
||||
and apply steps have separate responsibilities. Distinguish prompt text reuse
|
||||
from image-file duplicates, and metadata enrichment from thumbnail generation.
|
||||
No repeated global thumbnail-generation prompt on opening the gallery.
|
||||
|
||||
## Send to ComfyUI
|
||||
|
||||
Use the same original paper-plane outline, blue accent and accessible role label
|
||||
on every positive/negative prompt block, including image details and the viewer.
|
||||
A pair action sends both. Preview destination and exact fields before applying;
|
||||
positive-only and negative-only actions leave the other role untouched. An
|
||||
explicitly empty negative means clear the target field; unknown metadata must
|
||||
never be interpreted as empty. Sending text neither queues execution nor
|
||||
replaces a workflow.
|
||||
|
||||
Production needs a shared host bridge with versioned payloads, origin validation,
|
||||
destination discovery, capability checks, disconnected-host handling, linked-input
|
||||
checks and acknowledgements. Preview actual target values and report success only
|
||||
after acknowledgement. Default to asking for a destination; never guess between
|
||||
multiple nodes or positive and negative encoder inputs. Verify Classic and Nodes
|
||||
2.0 with both packs installed independently and together.
|
||||
|
||||
## Existing feature inventory
|
||||
|
||||
| Current feature | Proposed home / behavior | Disposition |
|
||||
| --- | --- | --- |
|
||||
| Admin prompt cards / per-prompt gallery | Prompt library + one prompt history workspace | Retained, rebuilt |
|
||||
| Standalone image gallery / Admin image modal | One Images workspace + shared Viewer.js viewer | Consolidated |
|
||||
| Video playback, autoplay / mute / loop, viewer slideshow and transforms | Shared media viewer; playback controls in Appearance; same image navigation | Retained |
|
||||
| Metadata page / viewer sidebar / full-text popups | One reusable metadata inspector; inline complete prompt text | Consolidated |
|
||||
| Prompt add, edit, rating, notes, copy, delete | Recipe editor, shared details, contextual bulk actions; reference-safe archive | Retained, clarified |
|
||||
| Categories, tag rename / merge, autocomplete | Tags & categories; same vocabulary in every editor | Retained |
|
||||
| Grid/list, thumbnail size, columns, pages/infinite scroll | Infinite scroll only; one Appearance configuration and contextual layout / density overrides | Simplified |
|
||||
| Scan, rescan, monitoring directories, worker threads | Sources & scans; explicit metadata enrichment vs thumbnail work | Consolidated |
|
||||
| Ollama, HuggingFace, WD14 variants, thresholds, review | One Auto-tagging configuration and review flow | Retained |
|
||||
| Thumbnail quality, size limit, metadata cache, paths | Appearance + cache controls in Maintenance | Retained |
|
||||
| Prompt dedup, hash checks, consistency, unused prompts | Maintenance plan with dependency checks and impact preview | Retained, safer flow |
|
||||
| Image dedup, orphan image links, browser cache | Separate reviewed operations in Maintenance | Retained |
|
||||
| Database backup / restore, JSON export | Backup & export; recovery and portable recipes clearly distinguished | Retained |
|
||||
| Diagnostics, image-link checks, logs, rotation, log levels | System → Diagnostics & logs | Retained |
|
||||
| LoRA detection/import, trigger injection, CivitAI key | System → Integrations; positive-only triggers | Retained |
|
||||
| Statistics / counts | System overview + usage breakdown; context counts in the workspace | Retained |
|
||||
| Send prompts to ComfyUI from every prompt surface | Role-aware paper-plane action + destination / overwrite preview | New proposal |
|
||||
| Reusable negatives / approved branch timeline | Prompt history with selected negative directly beneath positive | Approved concept |
|
||||
| Favorites, saved searches, project collections | Sidebar views; collections do not duplicate recipe text | New proposal |
|
||||
| Ambiguous metadata review, source provenance, run comparisons | Needs review queue; recorded vs inferred evidence; pair/run selection | New / expanded |
|
||||
| Unified jobs, cancellation, resumable progress visibility | Activity center; cancellation distinguished from hiding progress | New proposal |
|
||||
| Scheduled backups, pre-change safety snapshots | Explicit recovery policy after storage configuration | New proposal |
|
||||
|
||||
## Implementation boundaries and test requirements
|
||||
|
||||
Keep Viewer.js 1.15.2 and the shared toolkit as reproducible pinned artifacts;
|
||||
this mock uses their actual checked-in files without modifying them. Preserve
|
||||
legacy routes during the transition, current node layouts/schemas, existing
|
||||
controllers where reusable, and all existing test/coverage gates.
|
||||
|
||||
The timeline needs persisted execution records to represent real runs. Saved
|
||||
views, collections, metadata review resolution, activity history, scheduled
|
||||
backups, portable JSON import, and the ComfyUI bridge need separate implementation
|
||||
and persistence work. Preserve source evidence and distinguish recorded from
|
||||
inferred metadata. Existing schema migration is automatic; old image enrichment
|
||||
remains an optional explicit scan. The one-time upgrade notice belongs in the
|
||||
new shell, with Scan and Later choices.
|
||||
|
||||
Implement in reviewed slices: shared application shell and inspector; reusable
|
||||
prompt blocks/send bridge; image workspace; grouped library/history; settings and
|
||||
maintenance; optional organization and workflow improvements. Write regressions
|
||||
first for production behavior. Retain Python, frontend unit, Chromium/Firefox/
|
||||
WebKit/touch and managed Classic/Nodes 2.0 tests. Add behavior tests for shared
|
||||
filters, navigation state, viewer cleanup, interrupted jobs, dependency-safe
|
||||
maintenance, backup/restore, paired metadata and correct send destinations.
|
||||
|
||||
Screenshots in `screenshots/` record the reviewed starting point. The approved
|
||||
standalone timeline in `../prompt-variant-timeline.html` remains unchanged.
|
||||
|
||||
## Review polish
|
||||
|
||||
History actions use a shared branch icon. Images can switch among Grid, List and
|
||||
Masonry; Masonry preserves each image's proportions and keeps DOM order while
|
||||
packing cards with a responsive layout. It does not require experimental native
|
||||
masonry support. Resize observers are disconnected when leaving the view.
|
||||
|
||||
Every image card includes a tagging action beside Info, including cards in Needs
|
||||
review. Choose WD14 SwinV2, WD14 ViT, Ollama or HuggingFace; adjust thresholds or
|
||||
instructions, generate sample suggestions, edit tags manually, and approve only
|
||||
the desired suggestions. Changes are staged until approval, apply to that image
|
||||
alone and do not resolve uncertain prompt associations. Changing models discards
|
||||
previous suggestions. Cancel preserves existing tags. All inference is simulated.
|
||||
|
||||
## Continuous browsing decision
|
||||
|
||||
The redesign uses infinite scroll only. Remove page numbers, previous/next page
|
||||
buttons, paged/infinite mode selection and results-per-page controls. Keep request
|
||||
batching internal. The mock appends 12 images at a time in Grid, List and Masonry;
|
||||
it preserves existing card nodes, focus and selection while appending. It also
|
||||
retains the loaded range when switching layouts or revisiting a search. A
|
||||
keyboard-accessible Load more action provides an alternate trigger for the same
|
||||
continuous stream, not pagination. Exhausted and empty results are explicit.
|
||||
|
||||
Production will need bounded rendering, stable cursors, scroll restoration and
|
||||
loading/error/retry behavior for large collections. Existing backend pagination
|
||||
can continue providing internal request batches; no production tests or routes
|
||||
are removed as part of this prototype.
|
||||
@@ -0,0 +1,105 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>Images · Prompt Manager concept</title>
|
||||
<link rel="stylesheet" href="/toolkit/controls.css" />
|
||||
<link rel="stylesheet" href="/vendor/viewer.css" />
|
||||
<link rel="stylesheet" href="/workspace/style.css" />
|
||||
<script src="/vendor/viewer.js" defer></script>
|
||||
<script src="/workspace/app.js" type="module"></script>
|
||||
</head>
|
||||
<body class="ca-tools">
|
||||
<a class="skip" href="#main">Skip to workspace</a>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="#images"
|
||||
><span class="brand-mark">P<span>±</span></span
|
||||
><span>Prompt Manager<small>COMFYASSETS</small></span></a
|
||||
>
|
||||
<div class="sidebar-label">WORKSPACE</div>
|
||||
<nav id="primary-nav" aria-label="Workspace"></nav>
|
||||
<div class="sidebar-label">
|
||||
SAVED VIEWS <span class="new-label">PROPOSED</span>
|
||||
</div>
|
||||
<nav id="saved-nav" aria-label="Saved views"></nav>
|
||||
<div class="sidebar-label">
|
||||
COLLECTIONS <span class="new-label">PROPOSED</span>
|
||||
</div>
|
||||
<nav id="collections-nav" aria-label="Collections"></nav>
|
||||
<div class="sidebar-bottom">
|
||||
<nav id="system-nav" aria-label="Tools and system"></nav>
|
||||
<div class="connection">
|
||||
<i></i>
|
||||
<div>
|
||||
ComfyUI workspace<small
|
||||
>Demo connection · Classic / Nodes 2.0</small
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="workspace">
|
||||
<header class="topbar">
|
||||
<div class="breadcrumbs">
|
||||
ComfyAssets <span>/</span> <span id="breadcrumb">Images</span>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span class="concept-badge">Interactive concept</span
|
||||
><button id="activity-open" type="button">
|
||||
Activity <span class="count" id="activity-count">2</span></button
|
||||
><label class="theme-label" for="theme"
|
||||
>Theme
|
||||
<select id="theme" aria-label="Theme">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="system">System</option>
|
||||
</select></label
|
||||
><a class="canvas-link" href="#send"
|
||||
>ComfyUI <span aria-hidden="true">↗</span></a
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main" tabindex="-1"></main>
|
||||
<footer class="app-footer">
|
||||
<span
|
||||
>Design prototype · sample images and data · actions stay in this
|
||||
tab</span
|
||||
><button type="button" id="review-link">What changes & why</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" class="toast" role="status" aria-atomic="true"></div>
|
||||
<dialog id="modal" aria-labelledby="modal-title">
|
||||
<div class="dialog-header">
|
||||
<h2 id="modal-title"></h2>
|
||||
<button type="button" id="modal-close" aria-label="Close dialog">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div id="modal-body"></div>
|
||||
</dialog>
|
||||
<dialog id="viewer-dialog" aria-labelledby="viewer-title">
|
||||
<div class="dialog-header">
|
||||
<div>
|
||||
<h2 id="viewer-title">Image details</h2>
|
||||
<p class="muted small">
|
||||
One viewer · image, prompts, generation data, workflow
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" id="viewer-close" aria-label="Close image viewer">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="viewer-layout">
|
||||
<div class="viewer-left">
|
||||
<div id="viewer-stage"></div>
|
||||
<div class="viewer-toolbar" id="viewer-toolbar"></div>
|
||||
</div>
|
||||
<aside id="viewer-details" aria-label="Image metadata"></aside>
|
||||
</div>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 441 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 443 KiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 128 KiB |
@@ -0,0 +1,111 @@
|
||||
# Reusable positive and negative prompts
|
||||
|
||||
Tracking: [#193](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/193).
|
||||
This work builds on the merged Split node in PR #192. Keep the feature unmerged
|
||||
until maintainer live testing is complete.
|
||||
|
||||
## Storage contract
|
||||
|
||||
Keep existing prompt IDs, text hashes, metadata, image links and node schemas.
|
||||
A component can serve either role. Ordered combinations refer to shared prompt
|
||||
rows; an explicitly empty negative uses a null reference. Missing or ambiguous
|
||||
metadata does not mean an empty negative. Pair metadata belongs to the
|
||||
combination; saving another combination does not overwrite component metadata.
|
||||
Existing trim/lowercase hash normalization remains unchanged.
|
||||
|
||||
Saved components are immutable while referenced. To change text, create or reuse
|
||||
a component and save a new combination. Archive combinations that have generation
|
||||
history. Pruning and legacy editing/deletion must respect these dependencies.
|
||||
Historical workflow snapshots remain intact, including their original text.
|
||||
|
||||
## Migration and recovery
|
||||
|
||||
Create pair tables after existing legacy rebuild migrations, in an atomic,
|
||||
repeatable migration. Never scan media during schema initialization. Test fresh,
|
||||
legacy and current databases, repeated startup, injected failures and retries.
|
||||
Preserve old backup compatibility. Migrate and validate a disposable backup copy
|
||||
before replacing live database pages. Reject broken pair schemas, foreign keys,
|
||||
triggers and views. Use SQLite backup APIs so committed WAL data is included.
|
||||
|
||||
## Metadata and scanning
|
||||
|
||||
Resolve positive and negative from the same sampler/output context. Track both
|
||||
node ID and output slot; bound graph traversal and text construction. Unknown
|
||||
conditioning operations and conflicting sampler candidates remain unresolved.
|
||||
Never guess that the first encoder or most recent negative belongs to an image.
|
||||
|
||||
Import high-confidence pairs atomically with their image association and source
|
||||
provenance. Repeated scans must not duplicate components, pairs or image links,
|
||||
regenerate thumbnails, or increment generation run counts. Preserve existing
|
||||
conflicting/manual associations for review. Report new, enriched, unchanged,
|
||||
ambiguous, unresolved and failed results separately.
|
||||
|
||||
## Live node
|
||||
|
||||
Use the shared toolkit and Split foundation. Positive and negative editors have
|
||||
equal size. Only positive receives prepend, append and automatic LoRA triggers.
|
||||
Keep upstream connections authoritative. Library selection previews both fields
|
||||
before an explicit load. Saving text changes creates another reusable combination.
|
||||
Verify Classic and Nodes 2.0 startup, switching both directions, reload, caching,
|
||||
linked inputs, popup behavior and lifecycle cleanup before maintainer testing.
|
||||
|
||||
## Delivery gate
|
||||
|
||||
Keep all existing coverage floors and test suites. Record regression failures
|
||||
before implementing each behavior. Tests use disposable state and the managed
|
||||
CPU host; never mutate the maintainer's running ComfyUI or personal database.
|
||||
Python changes require a ComfyUI restart. Live approval is required before merge.
|
||||
|
||||
## Live testing
|
||||
|
||||
1. Download a database backup from Admin before the first restart. The additive
|
||||
schema migration runs on startup; existing workflows and prompt IDs stay valid.
|
||||
2. Restart ComfyUI, reload the browser, and add **Prompt Manager (+/-)** from
|
||||
**ComfyAssets / Prompts**. Connect CLIP and the two conditioning outputs to the
|
||||
sampler's positive and negative inputs.
|
||||
3. Enter both texts, save a pair, then keep positive unchanged and change negative.
|
||||
Recent should show both combinations with distinct negative excerpts. Selecting
|
||||
one previews both; **Use pair** loads both together.
|
||||
4. Test category/tag autocomplete and metadata opt-in, resizing, light/dark,
|
||||
switching Classic / Nodes 2.0 both ways, and workflow reload. Connected fields
|
||||
stay authoritative; run the node to save connected metadata values.
|
||||
5. Queue the same recipe twice and change only negative once. Usage should count
|
||||
dequeued jobs even when ComfyUI caches the node. Prepend, append and LoRA triggers
|
||||
affect positive only. Gallery text is labelled as recorded during execution or
|
||||
recovered from workflow metadata.
|
||||
6. Run an output metadata scan to enrich existing images. Repeat it: pairs and
|
||||
links must remain deduplicated, with unchanged results rather than new links.
|
||||
Conflicting or ambiguous files need review and are preserved.
|
||||
|
||||
Fresh executions embed bounded text evidence in PNG metadata. A cached node may
|
||||
not emit fresh evidence, so its graph-derived text is explicitly marked inferred.
|
||||
Multiple distinct batch records without an image anchor remain unresolved.
|
||||
Rescanning after interruption is safe through idempotent per-file writes; this
|
||||
version does not persist a filesystem scan cursor across server restarts.
|
||||
The API can request cancellation with `POST /prompt_manager/scan/cancel`; the
|
||||
existing dashboard Hide button continues to hide progress without cancelling.
|
||||
|
||||
Pair APIs are under `/prompt_manager/pairs`: POST saves a combination, GET lists
|
||||
and filters, GET/PATCH/DELETE `/{id}` reads, edits metadata or removes an unused
|
||||
pair. Changing text creates another combination. Null metadata updates preserve
|
||||
existing values; empty strings/lists clear supported fields. Archive a pair with
|
||||
`PATCH {"archived":true}`. GET `/export` provides versioned JSON containing both
|
||||
texts and pair metadata; this is an export format, not a JSON import endpoint.
|
||||
|
||||
## Gallery redesign concept retained
|
||||
|
||||
The maintainer approved the interactive
|
||||
[negative variant timeline concept](concepts/prompt-variant-timeline.html) on
|
||||
2026-10-17. Preserve it for the later gallery redesign; it is not production UI
|
||||
and does not expand the current node work.
|
||||
|
||||
Keep one positive parent and one shared gallery, with red branches for reusable
|
||||
negative variants and smaller dots for repeat runs. Show the selected negative
|
||||
in full directly beneath the unchanged positive; selecting another variant or
|
||||
run updates that text and filters the shared gallery. Comparison shows both
|
||||
negatives. Distinguish explicitly empty negatives from an all-variants view and
|
||||
saved variants from executed runs. Production history needs persisted execution
|
||||
records; the prototype's timeline uses illustrative sample data.
|
||||
|
||||
The Admin upgrade/scan notice remains a separate follow-up: explain automatic
|
||||
schema migration and offer an optional metadata enrichment scan or Later.
|
||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -296,3 +296,7 @@ class PromptManagerFocused(PromptManager):
|
||||
{**options, "default": "", "forceInput": True},
|
||||
)
|
||||
return inputs
|
||||
|
||||
|
||||
class PromptManagerSplit(PromptManagerFocused):
|
||||
"""The persistent library layout shares Focused encoding and storage."""
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Paired positive and negative conditioning with reusable recipe storage."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
try:
|
||||
from .prompt_manager import IO, InputTypeDict, PromptManagerFocused
|
||||
from .utils.hashing import generate_prompt_hash
|
||||
from .utils.pair_execution import record_pair_execution
|
||||
from .utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components
|
||||
from .utils.prompt_pair_resolver import resolve_text_input
|
||||
from .utils.usage_tracking import (
|
||||
PENDING_FIRST_USE,
|
||||
PENDING_PAIR_FIRST_USE,
|
||||
pair_run_key,
|
||||
)
|
||||
except ImportError:
|
||||
from prompt_manager import IO, InputTypeDict, PromptManagerFocused
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.pair_execution import record_pair_execution
|
||||
from utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components
|
||||
from utils.prompt_pair_resolver import resolve_text_input
|
||||
from utils.usage_tracking import (
|
||||
PENDING_FIRST_USE,
|
||||
PENDING_PAIR_FIRST_USE,
|
||||
pair_run_key,
|
||||
)
|
||||
|
||||
|
||||
class PromptManagerPair(PromptManagerFocused):
|
||||
"""Keep raw editors as one recipe while encoding their effective texts."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> InputTypeDict:
|
||||
inputs = super().INPUT_TYPES()
|
||||
inputs["required"] = {
|
||||
"text": inputs["required"]["text"],
|
||||
"negative_text": (
|
||||
IO.STRING,
|
||||
{"default": "", "multiline": True, "dynamicPrompts": True},
|
||||
),
|
||||
"clip": inputs["required"]["clip"],
|
||||
}
|
||||
inputs["hidden"]["extra_pnginfo"] = "EXTRA_PNGINFO"
|
||||
return inputs
|
||||
|
||||
RETURN_TYPES = (IO.CONDITIONING, IO.CONDITIONING, IO.STRING, IO.STRING)
|
||||
RETURN_NAMES = ("positive", "negative", "positive_text", "negative_text")
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"Positive conditioning with composition and optional LoRA trigger words.",
|
||||
"Negative conditioning from the negative editor, including empty text.",
|
||||
"The effective positive text that was encoded.",
|
||||
"The unchanged negative text that was encoded.",
|
||||
)
|
||||
DESCRIPTION = "Encode and save a reusable positive/negative prompt pair."
|
||||
|
||||
def encode_prompt(
|
||||
self,
|
||||
clip: Any,
|
||||
text: str,
|
||||
negative_text: str = "",
|
||||
category: str = "",
|
||||
tags: str = "",
|
||||
search_text: str = "",
|
||||
prepend_text: str = "",
|
||||
append_text: str = "",
|
||||
prompt: Optional[Dict[str, Any]] = None,
|
||||
unique_id: Optional[str] = None,
|
||||
extra_pnginfo: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[Any, Any, str, str]:
|
||||
"""Encode both sides once; database and tracking failures never block CLIP."""
|
||||
if clip is None:
|
||||
raise RuntimeError("ERROR: clip input is invalid: None")
|
||||
if not isinstance(text, str) or not isinstance(negative_text, str):
|
||||
raise ValueError("Positive and negative prompt text must be strings")
|
||||
positive = self._inject_lora_trigger_words(
|
||||
" ".join(
|
||||
part
|
||||
for part in (prepend_text.strip(), text, append_text.strip())
|
||||
if part
|
||||
)
|
||||
)
|
||||
positive_conditioning = clip.encode_from_tokens_scheduled(
|
||||
clip.tokenize(positive)
|
||||
)
|
||||
negative_conditioning = clip.encode_from_tokens_scheduled(
|
||||
clip.tokenize(negative_text)
|
||||
)
|
||||
record_pair_execution(
|
||||
extra_pnginfo,
|
||||
unique_id,
|
||||
text=text,
|
||||
negative_text=negative_text,
|
||||
positive_text=positive,
|
||||
negative_effective_text=negative_text,
|
||||
prepend_text=prepend_text.strip(),
|
||||
append_text=append_text.strip(),
|
||||
)
|
||||
pair_id = prompt_id = None
|
||||
known = isinstance(prompt, dict) and unique_id is not None
|
||||
node_id = str(unique_id)
|
||||
used_fields = (
|
||||
{
|
||||
field
|
||||
for source, field in run_prompt_components(prompt)
|
||||
if source == node_id
|
||||
}
|
||||
if known
|
||||
else {"text"}
|
||||
)
|
||||
is_positive = "text" in used_fields
|
||||
pair_used = node_id in intrinsic_pair_nodes(prompt) if known else True
|
||||
try:
|
||||
pair_id = self.db.save_prompt_pair(
|
||||
text,
|
||||
negative_text,
|
||||
category=category.strip() or None,
|
||||
tags=self._parse_tags(tags),
|
||||
)
|
||||
pair = self.db.get_prompt_pair(pair_id)
|
||||
prompt_id = pair["positive_prompt_id"]
|
||||
resolved_positive = (
|
||||
resolve_text_input(prompt, node_id, "text") if known else None
|
||||
)
|
||||
resolved_negative = (
|
||||
resolve_text_input(prompt, node_id, "negative_text") if known else None
|
||||
)
|
||||
positive_seen = resolved_positive is not None and generate_prompt_hash(
|
||||
resolved_positive
|
||||
) == generate_prompt_hash(text)
|
||||
pair_seen = (
|
||||
positive_seen
|
||||
and resolved_negative is not None
|
||||
and generate_prompt_hash(resolved_negative)
|
||||
== generate_prompt_hash(negative_text)
|
||||
)
|
||||
positive_pending = getattr(self, "pending_first_use", PENDING_FIRST_USE)
|
||||
pair_pending = getattr(
|
||||
self, "pending_pair_first_use", PENDING_PAIR_FIRST_USE
|
||||
)
|
||||
counted = set()
|
||||
for field, component_text, resolved_text, component_id in (
|
||||
("text", text, resolved_positive, prompt_id),
|
||||
(
|
||||
"negative_text",
|
||||
negative_text,
|
||||
resolved_negative,
|
||||
pair["negative_prompt_id"],
|
||||
),
|
||||
):
|
||||
component_hash = generate_prompt_hash(component_text)
|
||||
if (
|
||||
field not in used_fields
|
||||
or not component_id
|
||||
or component_hash in counted
|
||||
):
|
||||
continue
|
||||
counted.add(component_hash)
|
||||
seen_at_queue = (
|
||||
resolved_text is not None
|
||||
and generate_prompt_hash(resolved_text) == component_hash
|
||||
)
|
||||
runs = positive_pending.consume(component_hash) + int(not seen_at_queue)
|
||||
if runs:
|
||||
self.db.record_prompt_use(component_id, times=runs)
|
||||
if pair_used:
|
||||
runs = pair_pending.consume(pair_run_key(text, negative_text)) + int(
|
||||
not pair_seen
|
||||
)
|
||||
if runs:
|
||||
self.db.record_prompt_pair_use(pair_id, times=runs)
|
||||
if is_positive:
|
||||
self.prompt_tracker.set_current_prompt(
|
||||
prompt_text=positive,
|
||||
additional_data={
|
||||
"prompt_id": prompt_id,
|
||||
"pair_id": pair_id if pair_used else None,
|
||||
"positive_text": positive,
|
||||
"negative_text": negative_text,
|
||||
"positive_storage_text": text.strip(),
|
||||
"negative_storage_text": negative_text.strip(),
|
||||
"prepend_text": prepend_text.strip(),
|
||||
"append_text": append_text.strip(),
|
||||
"final_text": positive,
|
||||
"category": category.strip() or None,
|
||||
"tags": self._parse_tags(tags) or [],
|
||||
},
|
||||
push_to_queue=not positive_seen or (pair_used and not pair_seen),
|
||||
)
|
||||
except Exception as error:
|
||||
self.logger.warning(f"Could not save or track paired prompt: {error}")
|
||||
try:
|
||||
self.comfyui_integration.register_prompt(
|
||||
f"promptmanager_pair_{time.time_ns()}",
|
||||
positive,
|
||||
{
|
||||
"prompt_id": prompt_id,
|
||||
"pair_id": pair_id if pair_used else None,
|
||||
"negative_text": negative_text,
|
||||
"category": category.strip() or None,
|
||||
},
|
||||
)
|
||||
except Exception as error:
|
||||
self.logger.warning(f"Could not register paired metadata: {error}")
|
||||
return positive_conditioning, negative_conditioning, positive, negative_text
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
clip: Any,
|
||||
text: str = "",
|
||||
negative_text: str = "",
|
||||
category: str = "",
|
||||
tags: str = "",
|
||||
search_text: str = "",
|
||||
prepend_text: str = "",
|
||||
append_text: str = "",
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Either editor, composition or pair metadata invalidates the node cache."""
|
||||
serialized = json.dumps(
|
||||
[text, negative_text, prepend_text, append_text, category, tags]
|
||||
)
|
||||
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||
@@ -22,6 +22,7 @@ from aiohttp import web
|
||||
from PIL import Image
|
||||
|
||||
from .prompts import PromptRoutesMixin
|
||||
from .pairs import PairRoutesMixin
|
||||
from .images import ImageRoutesMixin
|
||||
from .admin import AdminRoutesMixin
|
||||
from .logging_routes import LoggingRoutesMixin
|
||||
@@ -84,6 +85,7 @@ def _public_error(exc: BaseException) -> str:
|
||||
# doubles as the allow-list.
|
||||
STATIC_MIME_TYPES = {
|
||||
".js": "application/javascript",
|
||||
".mjs": "application/javascript",
|
||||
".css": "text/css",
|
||||
".map": "application/json",
|
||||
".woff": "font/woff",
|
||||
@@ -240,6 +242,7 @@ async def _gzip_middleware(request, handler):
|
||||
|
||||
|
||||
class PromptManagerAPI(
|
||||
PairRoutesMixin,
|
||||
PromptRoutesMixin,
|
||||
ImageRoutesMixin,
|
||||
AdminRoutesMixin,
|
||||
@@ -262,12 +265,12 @@ class PromptManagerAPI(
|
||||
db (PromptDatabase): Database connection and operations handler
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the PromptManager API with database connection and cleanup."""
|
||||
def __init__(self, db: PromptDatabase | None = None) -> None:
|
||||
"""Initialize the API, using an injected database before any cleanup."""
|
||||
self.logger = get_logger("prompt_manager.api")
|
||||
self.logger.info("Initializing PromptManager API")
|
||||
|
||||
self.db = PromptDatabase()
|
||||
self.db = db if db is not None else PromptDatabase()
|
||||
self._cached_output_dir = None # Lazy-cached by _find_comfyui_output_dir()
|
||||
self._html_cache = {} # Cached HTML file contents keyed by path
|
||||
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
|
||||
@@ -361,6 +364,10 @@ class PromptManagerAPI(
|
||||
|
||||
# ── Web UI serving routes ─────────────────────────────────────
|
||||
|
||||
@routes.get("/prompt_manager/workspace")
|
||||
async def serve_workspace_ui(request: web.Request) -> web.Response:
|
||||
return self._serve_html_page("workspace.html", "Workspace")
|
||||
|
||||
@routes.get("/prompt_manager/web")
|
||||
async def serve_web_ui(request):
|
||||
return self._serve_html_page("index.html", "Web UI", cache=False)
|
||||
@@ -391,6 +398,7 @@ class PromptManagerAPI(
|
||||
|
||||
# ── Register domain-specific routes from mixins ───────────────
|
||||
|
||||
self._register_pair_routes(routes)
|
||||
self._register_prompt_routes(routes)
|
||||
self._register_image_routes(routes)
|
||||
self._register_admin_routes(routes)
|
||||
|
||||
@@ -26,8 +26,12 @@ except ImportError:
|
||||
|
||||
try:
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...database.pair_ingestion import ingest_resolved_pair
|
||||
from ...utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
except ImportError:
|
||||
from database.operations import PromptDatabase
|
||||
from database.pair_ingestion import ingest_resolved_pair
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
|
||||
try:
|
||||
@@ -40,6 +44,20 @@ VIDEO_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v", ".wmv")
|
||||
SCAN_BATCH_SIZE = 50 # Files per executor call during the output scan
|
||||
MAX_SAFETY_BACKUPS = 10 # '<db>.backup_*' files kept next to the live database
|
||||
MAX_SCAN_FILES = 50_000 # Upper bound on media files one scan will look at
|
||||
SCAN_COUNT_KEYS = (
|
||||
"processed",
|
||||
"found",
|
||||
"added",
|
||||
"linked",
|
||||
"enriched",
|
||||
"unchanged",
|
||||
"ambiguous",
|
||||
"unresolved",
|
||||
"conflict",
|
||||
"failed",
|
||||
"pairs_added",
|
||||
"pair_links_added",
|
||||
)
|
||||
MAX_SCAN_DEPTH = 12 # Directory levels below an output root that are scanned
|
||||
# Files the duplicate cleaner may delete: everything the scan reports, plus
|
||||
# the common single-f TIFF spelling. Anything else inside the output
|
||||
@@ -359,6 +377,10 @@ class AdminRoutesMixin:
|
||||
async def scan_images_route(request):
|
||||
return await self.scan_images(request)
|
||||
|
||||
@routes.post("/prompt_manager/scan/cancel")
|
||||
async def cancel_scan_route(request):
|
||||
return await self.cancel_scan(request)
|
||||
|
||||
@routes.get("/prompt_manager/scan/status")
|
||||
async def scan_status_route(request):
|
||||
return await self.scan_status(request)
|
||||
@@ -1280,6 +1302,14 @@ class AdminRoutesMixin:
|
||||
job = self._start_scan_job()
|
||||
return await self._stream_scan_job(request, job)
|
||||
|
||||
async def cancel_scan(self, request):
|
||||
"""Stop between file transactions; an in-flight extraction can finish."""
|
||||
job = self._scan_job
|
||||
requested = bool(job and job.running)
|
||||
if requested:
|
||||
job.cancelled = True
|
||||
return web.json_response({"success": True, "cancellation_requested": requested})
|
||||
|
||||
async def scan_status(self, request):
|
||||
"""Whether an output scan is running, and its latest progress event."""
|
||||
job = self._scan_job
|
||||
@@ -1351,7 +1381,7 @@ class AdminRoutesMixin:
|
||||
media_files = await self._run_in_executor(
|
||||
self._collect_output_media_sync, output_dirs
|
||||
)
|
||||
counts = {"processed": 0, "found": 0, "added": 0, "linked": 0}
|
||||
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
|
||||
if media_files:
|
||||
yield self._scan_progress(
|
||||
5, f"Found {len(media_files)} media files to process...", counts
|
||||
@@ -1364,7 +1394,9 @@ class AdminRoutesMixin:
|
||||
f"found={counts['found']}, new_prompts_added={counts['added']}, "
|
||||
f"images_linked_to_existing={counts['linked']}"
|
||||
)
|
||||
yield {"type": "complete", **counts, "directories": dir_names}
|
||||
job = self._scan_job
|
||||
final_type = "cancelled" if job and job.cancelled else "complete"
|
||||
yield {"type": final_type, **counts, "directories": dir_names}
|
||||
|
||||
except Exception:
|
||||
self.logger.exception("Scan error")
|
||||
@@ -1380,6 +1412,8 @@ class AdminRoutesMixin:
|
||||
"""
|
||||
total = len(media_files)
|
||||
for batch_start in range(0, total, SCAN_BATCH_SIZE):
|
||||
if self._scan_job and self._scan_job.cancelled:
|
||||
return
|
||||
batch = media_files[batch_start : batch_start + SCAN_BATCH_SIZE]
|
||||
batch_results = await self._run_in_executor(
|
||||
self._extract_batch_metadata_sync, batch
|
||||
@@ -1405,7 +1439,7 @@ class AdminRoutesMixin:
|
||||
"progress": progress,
|
||||
"status": status,
|
||||
"processed": counts.get("processed", 0),
|
||||
"found": counts.get("found", 0),
|
||||
**{key: counts.get(key, 0) for key in SCAN_COUNT_KEYS},
|
||||
}
|
||||
|
||||
def _collect_output_media_sync(self, output_dirs):
|
||||
@@ -1431,25 +1465,64 @@ class AdminRoutesMixin:
|
||||
|
||||
def _ingest_scan_batch_sync(self, batch_results):
|
||||
"""Store the prompts found in a metadata batch (blocking DB work)."""
|
||||
counts = {"processed": 0, "found": 0, "added": 0, "linked": 0}
|
||||
counts = dict.fromkeys(SCAN_COUNT_KEYS, 0)
|
||||
for media_file, metadata in batch_results:
|
||||
if self._scan_job and self._scan_job.cancelled:
|
||||
break
|
||||
counts["processed"] += 1
|
||||
try:
|
||||
outcome = self._ingest_scanned_file(media_file, metadata)
|
||||
outcome = self._ingest_scanned_file(media_file, metadata, detailed=True)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing {media_file.name}: {e}")
|
||||
counts["failed"] += 1
|
||||
continue
|
||||
if outcome is None:
|
||||
continue
|
||||
counts["found"] += 1
|
||||
if outcome in ("added", "linked"):
|
||||
counts[outcome] += 1
|
||||
status = outcome["status"] if isinstance(outcome, dict) else outcome
|
||||
if status in (
|
||||
"added",
|
||||
"linked",
|
||||
"enriched",
|
||||
"unchanged",
|
||||
"ambiguous",
|
||||
"unresolved",
|
||||
"conflict",
|
||||
"failed",
|
||||
):
|
||||
counts[status] += 1
|
||||
if isinstance(outcome, dict):
|
||||
counts["pairs_added"] += outcome.get("pairs_added", 0)
|
||||
counts["pair_links_added"] += outcome.get("links_added", 0)
|
||||
return counts
|
||||
|
||||
def _ingest_scanned_file(self, media_file, metadata):
|
||||
"""Save or link one scanned file; returns 'added', 'linked', 'found' or None."""
|
||||
def _ingest_scanned_file(self, media_file, metadata, detailed=False):
|
||||
"""Enrich exact pairs; retain legacy positive-only import for absent pairs."""
|
||||
if not metadata:
|
||||
return None
|
||||
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 not canonical.get("prompt") and not canonical.get("parameters"):
|
||||
return None
|
||||
resolution = resolve_prompt_pair(canonical)
|
||||
if resolution["state"] == "resolved":
|
||||
result = ingest_resolved_pair(
|
||||
self.db, str(media_file), canonical, resolution
|
||||
)
|
||||
return result if detailed else result["status"]
|
||||
if resolution["state"] == "ambiguous":
|
||||
return "ambiguous"
|
||||
if resolution["state"] == "unresolved":
|
||||
if canonical.get("prompt") or not isinstance(
|
||||
canonical.get("parameters"), str
|
||||
):
|
||||
return resolution["state"]
|
||||
parsed = self._parse_comfyui_prompt(metadata)
|
||||
if not (parsed.get("prompt") or parsed.get("parameters")):
|
||||
return None
|
||||
@@ -1465,13 +1538,15 @@ class AdminRoutesMixin:
|
||||
existing = self.db.get_prompt_by_hash(prompt_hash)
|
||||
if existing:
|
||||
try:
|
||||
self.db.link_image_to_prompt(existing["id"], str(media_file))
|
||||
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"
|
||||
return "linked" if image_id else "unchanged"
|
||||
|
||||
prompt_id = self.db.save_prompt(
|
||||
prompt_text,
|
||||
@@ -1484,7 +1559,7 @@ class AdminRoutesMixin:
|
||||
if not prompt_id:
|
||||
return "found"
|
||||
try:
|
||||
self.db.link_image_to_prompt(prompt_id, str(media_file))
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Reusable ordered prompt combinations, separate from legacy prompt CRUD."""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .prompts import parse_page_params
|
||||
|
||||
try:
|
||||
from ...database.pairs import PairDependencyError
|
||||
from ...utils.validators import (
|
||||
validate_category,
|
||||
validate_prompt_text,
|
||||
validate_rating,
|
||||
validate_tags,
|
||||
)
|
||||
except ImportError:
|
||||
from database.pairs import PairDependencyError
|
||||
from utils.validators import (
|
||||
validate_category,
|
||||
validate_prompt_text,
|
||||
validate_rating,
|
||||
validate_tags,
|
||||
)
|
||||
|
||||
|
||||
def pair_response(handler: Callable) -> Callable:
|
||||
@wraps(handler)
|
||||
async def wrapped(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
return await handler(self, request)
|
||||
except PairDependencyError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=409)
|
||||
except (ValueError, TypeError) as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except Exception:
|
||||
self.logger.exception("Prompt pair operation failed")
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Prompt pair operation failed"}, status=500
|
||||
)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def metadata_fields(data: dict) -> dict:
|
||||
fields = {}
|
||||
for name in ("title", "category", "tags", "rating", "notes"):
|
||||
if name not in data:
|
||||
continue
|
||||
value = data[name]
|
||||
if name == "tags":
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("Tags must be a list of strings")
|
||||
validate_tags(value)
|
||||
elif name == "rating":
|
||||
if isinstance(value, bool):
|
||||
raise ValueError("Rating must be an integer")
|
||||
validate_rating(value)
|
||||
else:
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ValueError(f"{name} must be a string")
|
||||
if name == "category":
|
||||
validate_category(value)
|
||||
elif value is not None and len(value) > 10000:
|
||||
raise ValueError(f"{name} is too long")
|
||||
fields[name] = value
|
||||
return fields
|
||||
|
||||
|
||||
async def object_body(request: web.Request) -> dict:
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Expected a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def missing_pair() -> web.Response:
|
||||
return web.json_response({"success": False, "error": "Pair not found"}, status=404)
|
||||
|
||||
|
||||
class PairRoutesMixin:
|
||||
def _register_pair_routes(self, routes: web.RouteTableDef) -> None:
|
||||
routes.get("/prompt_manager/pairs/export")(self.export_prompt_pairs)
|
||||
routes.get("/prompt_manager/pairs")(self.list_prompt_pairs)
|
||||
routes.post("/prompt_manager/pairs")(self.save_prompt_pair)
|
||||
routes.get("/prompt_manager/pairs/{pair_id}")(self.get_prompt_pair)
|
||||
routes.patch("/prompt_manager/pairs/{pair_id}")(self.update_prompt_pair)
|
||||
routes.delete("/prompt_manager/pairs/{pair_id}")(self.delete_prompt_pair)
|
||||
|
||||
@pair_response
|
||||
async def save_prompt_pair(self, request: web.Request) -> web.Response:
|
||||
data = await object_body(request)
|
||||
positive = data.get("positive_text")
|
||||
negative = data.get("negative_text")
|
||||
validate_prompt_text(positive)
|
||||
if not isinstance(negative, str):
|
||||
raise ValueError(
|
||||
"negative_text must be explicit; use an empty string for none"
|
||||
)
|
||||
if negative.strip():
|
||||
validate_prompt_text(negative)
|
||||
fields = metadata_fields(data)
|
||||
pair_id = await self._run_in_executor(
|
||||
self.db.save_prompt_pair, positive, negative, **fields
|
||||
)
|
||||
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
|
||||
return web.json_response({"success": True, "pair": pair})
|
||||
|
||||
@pair_response
|
||||
async def get_prompt_pair(self, request: web.Request) -> web.Response:
|
||||
pair = await self._run_in_executor(
|
||||
self.db.get_prompt_pair, int(request.match_info["pair_id"])
|
||||
)
|
||||
return (
|
||||
web.json_response({"success": True, "pair": pair})
|
||||
if pair
|
||||
else missing_pair()
|
||||
)
|
||||
|
||||
@pair_response
|
||||
async def list_prompt_pairs(self, request: web.Request) -> web.Response:
|
||||
limit, offset = parse_page_params(request.query)
|
||||
archived = request.query.get("include_archived", "false")
|
||||
if archived not in ("true", "false"):
|
||||
raise ValueError("include_archived must be true or false")
|
||||
result = await self._run_in_executor(
|
||||
self.db.list_prompt_pairs,
|
||||
text=request.query.get("text"),
|
||||
category=request.query.get("category"),
|
||||
tags=request.query.getall("tag", []),
|
||||
include_archived=archived == "true",
|
||||
sort=request.query.get("sort", "newest"),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
@pair_response
|
||||
async def update_prompt_pair(self, request: web.Request) -> web.Response:
|
||||
pair_id = int(request.match_info["pair_id"])
|
||||
data = await object_body(request)
|
||||
allowed = {"title", "category", "tags", "rating", "notes", "archived"}
|
||||
if not data or set(data) - allowed:
|
||||
raise ValueError(
|
||||
"Update metadata or archive; save a new pair to change text"
|
||||
)
|
||||
fields = metadata_fields(data)
|
||||
if "archived" in data:
|
||||
if not isinstance(data["archived"], bool) or fields:
|
||||
raise ValueError("Archive must be a separate boolean update")
|
||||
updated = await self._run_in_executor(
|
||||
self.db.archive_prompt_pair, pair_id, data["archived"]
|
||||
)
|
||||
else:
|
||||
updated = await self._run_in_executor(
|
||||
self.db.update_prompt_pair_metadata, pair_id, **fields
|
||||
)
|
||||
pair = await self._run_in_executor(self.db.get_prompt_pair, pair_id)
|
||||
if not updated and not pair:
|
||||
return missing_pair()
|
||||
return web.json_response({"success": True, "pair": pair})
|
||||
|
||||
@pair_response
|
||||
async def delete_prompt_pair(self, request: web.Request) -> web.Response:
|
||||
deleted = await self._run_in_executor(
|
||||
self.db.delete_prompt_pair, int(request.match_info["pair_id"])
|
||||
)
|
||||
return web.json_response({"success": True}) if deleted else missing_pair()
|
||||
|
||||
@pair_response
|
||||
async def export_prompt_pairs(self, request: web.Request) -> web.Response:
|
||||
pairs = []
|
||||
offset = 0
|
||||
while True:
|
||||
result = await self._run_in_executor(
|
||||
self.db.list_prompt_pairs,
|
||||
include_archived=True,
|
||||
limit=500,
|
||||
offset=offset,
|
||||
)
|
||||
for pair in result["pairs"]:
|
||||
pairs.append(
|
||||
{
|
||||
key: pair[key]
|
||||
for key in (
|
||||
"positive_text",
|
||||
"negative_text",
|
||||
"title",
|
||||
"category",
|
||||
"tags",
|
||||
"rating",
|
||||
"notes",
|
||||
"archived",
|
||||
)
|
||||
}
|
||||
)
|
||||
offset += len(result["pairs"])
|
||||
if not result["has_more"] or not result["pairs"]:
|
||||
break
|
||||
return web.json_response(
|
||||
{"schema_version": 1, "kind": "prompt-manager-pairs", "pairs": pairs},
|
||||
headers={"Content-Disposition": 'attachment; filename="prompt-pairs.json"'},
|
||||
)
|
||||
@@ -6,6 +6,11 @@ import os
|
||||
import sqlite3
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ...database.pairs import PairDependencyError
|
||||
except ImportError:
|
||||
from database.pairs import PairDependencyError
|
||||
from urllib.parse import quote
|
||||
|
||||
# Upper bounds on page sizes and offsets for every list endpoint. The offset
|
||||
@@ -793,6 +798,8 @@ class PromptRoutesMixin:
|
||||
status=404,
|
||||
)
|
||||
|
||||
except PairDependencyError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=409)
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid prompt ID"}, status=400
|
||||
@@ -840,6 +847,8 @@ class PromptRoutesMixin:
|
||||
{"success": False, "error": "Prompt not found"}, status=404
|
||||
)
|
||||
|
||||
except PairDependencyError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=409)
|
||||
except sqlite3.IntegrityError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Another prompt already has this text"},
|
||||
@@ -1081,6 +1090,8 @@ class PromptRoutesMixin:
|
||||
}
|
||||
)
|
||||
|
||||
except PairDependencyError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=409)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Bulk delete error: {e}")
|
||||
return web.json_response(
|
||||
|
||||
@@ -16,6 +16,7 @@ class ScanJob:
|
||||
def __init__(self):
|
||||
self.last_event = None
|
||||
self.finished = False
|
||||
self.cancelled = False
|
||||
self.task = None
|
||||
self._subscribers = set()
|
||||
|
||||
|
||||
@@ -2,6 +2,17 @@ import {readFileSync} from 'node:fs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
for (const variant of ['Focused', 'Split']) {
|
||||
test.describe(variant, () => {
|
||||
const isSplit = variant === 'Split';
|
||||
const uiProperty = isSplit ? 'splitUI' : 'focusedUI';
|
||||
async function openLibrary(page) {
|
||||
if (isSplit) {
|
||||
await expect(page.locator('.pm-editor-library')).toBeVisible();
|
||||
await expect(page.getByRole('button',{name:'Browse library',exact:true})).toHaveCount(0);
|
||||
} else await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
}
|
||||
|
||||
test.beforeEach(async({page,browserName})=>{
|
||||
await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true}));
|
||||
await startCoverage(page,browserName);
|
||||
@@ -10,26 +21,26 @@ test.beforeEach(async({page,browserName})=>{
|
||||
await page.route('**/scripts/api.js',route=>route.fulfill({contentType:'text/javascript',body:'export const api={fileURL:path=>path,queuePrompt:async(...args)=>args};'}));
|
||||
await page.route('**/extensions/promptmanager/prompt_manager.js',route=>route.fulfill({contentType:'text/javascript',body:readFileSync(new URL('../../web/comfy/prompt_manager.js',import.meta.url),'utf8')}));
|
||||
await page.goto('/prompt_manager/test');
|
||||
await page.evaluate(async()=>{
|
||||
await page.evaluate(async variant=>{
|
||||
document.body.replaceChildren();document.body.style.setProperty('--bg-color','#222');
|
||||
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}};
|
||||
await import('/extensions/promptmanager/prompt_manager.js');
|
||||
class Node {
|
||||
constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[550,700];}
|
||||
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
|
||||
addDOMWidget(name,type,element){document.body.append(element);return {element};}
|
||||
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.height='650px';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
|
||||
setSize(size){this.size=size;}setDirtyCanvas(){}
|
||||
serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
|
||||
configure(data){this.widgets.forEach((widget,index)=>{widget.value=data.widgets_values[index];});this.properties={...data.properties};}
|
||||
}
|
||||
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:'PromptManagerFocused'},fixtureApp);
|
||||
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:`PromptManager${variant}`},fixtureApp);
|
||||
window.node=new Node();node.onNodeCreated();
|
||||
});
|
||||
},variant);
|
||||
await expect(page.locator('.pm-focused')).toBeVisible();
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
|
||||
|
||||
test('Focused library autocompletes filters and searches without changing editor metadata', async ({page}) => {
|
||||
test(`${variant} library autocompletes filters and searches without changing editor metadata`, async ({page}) => {
|
||||
const requests=[];
|
||||
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}}));
|
||||
await page.route('**/prompt_manager/tags/suggest?**',route=>route.fulfill({json:{success:true,suggestions:[{name:'studio'}]}}));
|
||||
@@ -38,7 +49,7 @@ test('Focused library autocompletes filters and searches without changing editor
|
||||
return route.fulfill({json:{success:true,results:[{text:'Filtered portrait',category:'Portrait',tags:['studio']}]}});
|
||||
});
|
||||
await page.getByLabel('Main prompt',{exact:true}).fill('My draft');
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
await openLibrary(page);
|
||||
const category=page.getByLabel('Filter category',{exact:true});
|
||||
await category.fill('por');
|
||||
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
|
||||
@@ -60,7 +71,7 @@ test('Focused library autocompletes filters and searches without changing editor
|
||||
await category.fill('Landscape');
|
||||
await expect(page.locator('.pm-editor-selection button')).toBeDisabled();
|
||||
const saved=await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);return saved;});
|
||||
expect(saved.properties.focusedUI.libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
|
||||
expect(saved.properties[uiProperty].libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
|
||||
await expect(category).toHaveValue('Landscape');
|
||||
await expect(tags).toHaveValue('studio, ');
|
||||
let recentRequest;
|
||||
@@ -69,14 +80,15 @@ test('Focused library autocompletes filters and searches without changing editor
|
||||
await expect.poll(()=>Boolean(recentRequest)).toBe(true);
|
||||
expect(recentRequest.searchParams.has('category')).toBe(false);
|
||||
expect(recentRequest.searchParams.has('tags')).toBe(false);
|
||||
await page.getByRole('button',{name:'Hide library',exact:true}).click();
|
||||
if (isSplit) await page.getByRole('searchbox',{name:'Search saved prompts'}).focus();
|
||||
else await page.getByRole('button',{name:'Hide library',exact:true}).click();
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Focused library can search by filters alone and Enter searches after autocomplete closes', async ({page}) => {
|
||||
test(`${variant} library can search by filters alone and Enter searches after autocomplete closes`, async ({page}) => {
|
||||
const requests=[];
|
||||
await page.route('**/prompt_manager/search?**',route=>{requests.push(new URL(route.request().url()));return route.fulfill({json:{success:true,results:[]}});});
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
await openLibrary(page);
|
||||
await page.getByLabel('Filter category',{exact:true}).fill('New category');
|
||||
await page.getByLabel('Filter category',{exact:true}).press('Enter');
|
||||
await expect.poll(()=>requests.length).toBe(1);
|
||||
@@ -88,7 +100,7 @@ test('Focused library can search by filters alone and Enter searches after autoc
|
||||
expect(requests[1].searchParams.get('tags')).toBe('custom');
|
||||
});
|
||||
|
||||
test('Focused completes saved tags with keyboard and pointer and appends commas', async ({page}) => {
|
||||
test(`${variant} completes saved tags with keyboard and pointer and appends commas`, async ({page}) => {
|
||||
await page.route('**/prompt_manager/tags/suggest?**', route => {
|
||||
const q = new URL(route.request().url()).searchParams.get('q');
|
||||
return route.fulfill({json:{success:true,suggestions:(q === 'bl' ? ['blue eyes','blue sky'] : ['portrait','blue eyes']).map(name => ({name}))}});
|
||||
@@ -118,7 +130,7 @@ test('Focused completes saved tags with keyboard and pointer and appends commas'
|
||||
await expect(tags).toHaveValue('A brand new tag');
|
||||
});
|
||||
|
||||
test('Focused metadata autocomplete ignores stale responses, recovers from errors and cleans up', async ({page}) => {
|
||||
test(`${variant} metadata autocomplete ignores stale responses, recovers from errors and cleans up`, async ({page}) => {
|
||||
let release;
|
||||
let mode='delayed';
|
||||
await page.route('**/prompt_manager/tags/suggest?**', async route => {
|
||||
@@ -146,7 +158,7 @@ test('Focused metadata autocomplete ignores stale responses, recovers from error
|
||||
await expect(page.getByRole('listbox')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Focused completes one category, allows new values and dismisses suggestions on blur', async ({page}) => {
|
||||
test(`${variant} completes one category, allows new values and dismisses suggestions on blur`, async ({page}) => {
|
||||
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait','Landscape','Portrait studio']}}));
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
const category=page.getByLabel('Category',{exact:true});
|
||||
@@ -168,7 +180,7 @@ test('Focused completes one category, allows new values and dismisses suggestion
|
||||
await expect(category).toHaveValue('New category');
|
||||
});
|
||||
|
||||
test('Focused shares suggestion controls safely with another bundled toolkit copy', async ({page}) => {
|
||||
test(`${variant} shares suggestion controls safely with another bundled toolkit copy`, async ({page}) => {
|
||||
await page.evaluate(async()=>{
|
||||
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
|
||||
const first=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=first');
|
||||
@@ -196,10 +208,10 @@ test('Focused shares suggestion controls safely with another bundled toolkit cop
|
||||
await expect(lists).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Focused previews before loading, keeps metadata optional and composes text',async({page})=>{
|
||||
test(`${variant} previews before loading, keeps metadata optional and composes text`,async({page})=>{
|
||||
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:1,text:'Library portrait',category:'Portrait',tags:['studio']}]}}));
|
||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('My draft');
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
await openLibrary(page);
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await page.getByRole('button',{name:/Library portrait/}).click();
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('My draft');
|
||||
@@ -214,7 +226,7 @@ test('Focused previews before loading, keeps metadata optional and composes text
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,6))).toEqual(['Library portrait','','','','trigger','detailed']);
|
||||
});
|
||||
|
||||
test('Focused restores workflow values and disclosures and protects connected text',async({page})=>{
|
||||
test(`${variant} restores workflow values and disclosures and protects connected text`,async({page})=>{
|
||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Saved draft');
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
await page.getByLabel('Category',{exact:true}).fill('Saved category');
|
||||
@@ -228,14 +240,14 @@ test('Focused restores workflow values and disclosures and protects connected te
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Saved draft');
|
||||
});
|
||||
|
||||
test('Focused searches quoted tags with Enter and explicitly loads metadata', async ({page}) => {
|
||||
test(`${variant} searches quoted tags with Enter and explicitly loads metadata`, async ({page}) => {
|
||||
let requested;
|
||||
await page.route('**/prompt_manager/search?**', async route => {
|
||||
requested = new URL(route.request().url());
|
||||
await route.fulfill({json:{success:true,results:[{id:7,text:'Selected fox',category:'Animals',tags:['film grain','fox']}]}});
|
||||
});
|
||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Preserved draft');
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
await openLibrary(page);
|
||||
const query=page.getByRole('searchbox',{name:'Search saved prompts'});
|
||||
await query.fill('fox tag:"film grain"');
|
||||
await query.press('Enter');
|
||||
@@ -250,7 +262,7 @@ test('Focused searches quoted tags with Enter and explicitly loads metadata', as
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,4))).toEqual(['Selected fox','Animals','film grain, fox','fox tag:"film grain"']);
|
||||
});
|
||||
|
||||
test('Focused reports empty queries, no results, and safely renders backend and HTTP errors', async ({page}) => {
|
||||
test(`${variant} reports empty queries, no results, and safely renders backend and HTTP errors`, async ({page}) => {
|
||||
let requests=0;
|
||||
let mode='empty';
|
||||
const malicious='<img src=x onerror="window.focusedInjected=true">';
|
||||
@@ -259,7 +271,7 @@ test('Focused reports empty queries, no results, and safely renders backend and
|
||||
if(mode==='http') return route.fulfill({status:503,body:'Unavailable'});
|
||||
await route.fulfill({json:mode==='error'?{success:false,error:malicious}:{success:true,results:[]}});
|
||||
});
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
await openLibrary(page);
|
||||
const search=page.getByRole('button',{name:'Search',exact:true});
|
||||
await search.click();
|
||||
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
|
||||
@@ -287,10 +299,10 @@ test('Focused reports empty queries, no results, and safely renders backend and
|
||||
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
|
||||
});
|
||||
|
||||
test('Focused treats saved prompt content and metadata as text', async ({page}) => {
|
||||
test(`${variant} treats saved prompt content and metadata as text`, async ({page}) => {
|
||||
const malicious='<img src=x onerror="window.focusedInjected=true">';
|
||||
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'<script>bad()</script>',tags:['<svg onload=bad()>']}]}}));
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
await openLibrary(page);
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await page.locator('.pm-editor-result').click();
|
||||
await expect(page.locator('.pm-editor-selection .pm-editor-preview')).toHaveText(malicious);
|
||||
@@ -312,9 +324,9 @@ async function deferLibraryRequests(page) {
|
||||
});
|
||||
}
|
||||
|
||||
test('Focused keeps the latest result when searches and recent requests overlap', async ({page}) => {
|
||||
test(`${variant} keeps the latest result when searches and recent requests overlap`, async ({page}) => {
|
||||
await deferLibraryRequests(page);
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
await openLibrary(page);
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('older search');
|
||||
await page.getByRole('button',{name:'Search',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
@@ -333,9 +345,9 @@ test('Focused keeps the latest result when searches and recent requests overlap'
|
||||
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
|
||||
});
|
||||
|
||||
test('Focused removal aborts work and stops theme and control listeners', async ({page}) => {
|
||||
test(`${variant} removal aborts work and stops theme and control listeners`, async ({page}) => {
|
||||
await deferLibraryRequests(page);
|
||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||
await openLibrary(page);
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
|
||||
const removed=await page.evaluate(()=>{
|
||||
@@ -348,11 +360,18 @@ test('Focused removal aborts work and stops theme and control listeners', async
|
||||
await page.evaluate(()=>completeLibraryRequest(0,'Late result after removal'));
|
||||
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
|
||||
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
|
||||
await page.getByRole('button',{name:'Hide library',exact:true}).click();
|
||||
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible();
|
||||
if (isSplit) {
|
||||
const count=await page.evaluate(()=>libraryRequests.length);
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
expect(await page.evaluate(()=>libraryRequests.length)).toBe(count);
|
||||
await expect(page.locator('.pm-editor-library')).toBeVisible();
|
||||
} else {
|
||||
await page.getByRole('button',{name:'Hide library',exact:true}).click();
|
||||
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Focused fields reflow with node width and follow live host themes', async ({page}) => {
|
||||
test(`${variant} fields reflow with node width and follow live host themes`, async ({page}) => {
|
||||
await page.setViewportSize({width:1000,height:1000});
|
||||
await page.getByText('Composition · prepend / append',{exact:true}).click();
|
||||
await page.evaluate(()=>{node._focusedView.element.style.width='700px';node._focusedView.element.style.height='850px';});
|
||||
@@ -373,7 +392,7 @@ test('Focused fields reflow with node width and follow live host themes', async
|
||||
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).toBe(dark);
|
||||
});
|
||||
|
||||
test('Focused composed preview preserves main prompt whitespace like queued execution', async ({page}) => {
|
||||
test(`${variant} composed preview preserves main prompt whitespace like queued execution`, async ({page}) => {
|
||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill(' draft ');
|
||||
await page.getByText('Composition · prepend / append',{exact:true}).click();
|
||||
await page.getByLabel('Prepend',{exact:true}).fill(' trigger ');
|
||||
@@ -382,17 +401,17 @@ test('Focused composed preview preserves main prompt whitespace like queued exec
|
||||
expect(await page.locator('[data-composed-preview]').textContent()).toBe('trigger draft detail');
|
||||
});
|
||||
|
||||
test('Focused exposes the visible text element for autocomplete and host extension listeners', async ({page}) => {
|
||||
test(`${variant} exposes the visible text element for autocomplete and host extension listeners`, async ({page}) => {
|
||||
expect(await page.evaluate(()=>node.widgets.find(widget=>widget.name==='text').inputEl===document.querySelector('.pm-focused textarea'))).toBe(true);
|
||||
});
|
||||
|
||||
test('Focused expands its main editor when the node gets taller',async({page})=>{
|
||||
await page.locator('.pm-focused').evaluate(element=>{element.style.width='550px';element.style.height='800px';});
|
||||
test(`${variant} expands its main editor when the node gets taller`,async({page})=>{
|
||||
await page.locator('.pm-focused').evaluate((element,isSplit)=>{element.style.width=isSplit?'900px':'550px';element.style.height='800px';},isSplit);
|
||||
const editor=await page.getByRole('textbox',{name:'Main prompt',exact:true}).boundingBox();
|
||||
expect(editor.height).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
test('Focused respects the existing Web UI display preference',async({page})=>{
|
||||
test(`${variant} respects the existing Web UI display preference`,async({page})=>{
|
||||
await page.route('**/prompt_manager/settings',route=>route.fulfill({json:{success:true,settings:{webui_display_mode:'newtab'}}}));
|
||||
await page.evaluate(()=>{
|
||||
const Node=node.constructor;node.onRemoved();node._focusedView.element.remove();node=new Node();node.onNodeCreated();
|
||||
@@ -403,9 +422,68 @@ test('Focused respects the existing Web UI display preference',async({page})=>{
|
||||
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']);
|
||||
});
|
||||
|
||||
test('Focused reports blocked Web UI popups without leaving the workflow',async({page})=>{
|
||||
test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{
|
||||
await page.evaluate(()=>{window.open=()=>null;});
|
||||
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
|
||||
await expect(page.locator('.pm-focused [role="alert"]')).toContainText('Allow popups');
|
||||
await expect(page.locator('.pm-focused')).toBeVisible();
|
||||
});
|
||||
|
||||
if (isSplit) test('Split keeps the library beside the editor when wide and stacks it when narrow', async ({page}) => {
|
||||
await page.setViewportSize({width:1200,height:1000});
|
||||
const root=page.locator('.pm-split');
|
||||
await expect(root).toHaveAttribute('aria-label','Split prompt editor');
|
||||
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toHaveCount(0);
|
||||
await root.evaluate(element=>{element.style.width='900px';element.style.height='800px';});
|
||||
const compose=root.locator('.pm-editor-workspace > .pm-editor-compose');
|
||||
const library=root.locator('.pm-editor-workspace > .pm-editor-library');
|
||||
await expect(compose.locator('.pm-editor-main')).toBeVisible();
|
||||
let a=await compose.boundingBox(),b=await library.boundingBox();
|
||||
expect(Math.abs(a.y-b.y)).toBeLessThan(2);
|
||||
expect(b.x).toBeGreaterThanOrEqual(a.x+a.width);
|
||||
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{text:'Library example'}]}}));
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
await expect(page.locator('.pm-editor-result')).toContainText('Library example');
|
||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Editing beside the library');
|
||||
await expect(library).toBeVisible();
|
||||
await expect(page.locator('.pm-editor-result')).toContainText('Library example');
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values[0])).toBe('Editing beside the library');
|
||||
await root.evaluate(element=>{element.style.width='340px';});
|
||||
await expect.poll(async()=>{const a=await compose.boundingBox(),b=await library.boundingBox();return b.y>=a.y+a.height;}).toBe(true);
|
||||
expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Editing beside the library');
|
||||
await expect(library).toBeVisible();
|
||||
});
|
||||
if (isSplit) test('Split bounds long recent results and selected previews at the default node size', async ({page}) => {
|
||||
await page.setViewportSize({width:1200,height:1000});
|
||||
const root=page.locator('.pm-split');
|
||||
await root.evaluate(element=>{element.style.width='900px';element.style.height='650px';});
|
||||
const longPrompt='Long saved prompt '+('detailed portrait in a studio with natural light '.repeat(110));
|
||||
const prompts=Array.from({length:20},(_,index)=>({id:index+1,text:index===0?longPrompt:`Saved prompt ${index+1}`,category:'Portrait',tags:['studio']}));
|
||||
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:prompts}}));
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||
const results=root.locator('.pm-editor-results');
|
||||
await expect(results.locator('.pm-editor-result')).toHaveCount(20);
|
||||
const rowHeights=await results.locator('.pm-editor-result > span:first-child').evaluateAll(elements=>elements.map(element=>element.getBoundingClientRect().height));
|
||||
expect(rowHeights.every(height=>height>0)).toBe(true);
|
||||
const resultSize=await results.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight}));
|
||||
expect.soft(resultSize.height).toBeLessThanOrEqual(300);
|
||||
expect(resultSize.scroll).toBeGreaterThan(resultSize.client);
|
||||
await results.locator('.pm-editor-result').first().click();
|
||||
const preview=root.locator('.pm-editor-selection > .pm-editor-preview');
|
||||
await expect(preview).toHaveText(longPrompt);
|
||||
const previewSize=await preview.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight}));
|
||||
expect.soft(previewSize.height).toBeLessThanOrEqual(180);
|
||||
expect.soft(previewSize.scroll).toBeGreaterThan(previewSize.client);
|
||||
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
|
||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(longPrompt);
|
||||
const bounds=await root.boundingBox();
|
||||
for (const selector of ['.pm-editor-compose','.pm-editor-library']) {
|
||||
const pane=await root.locator(selector).boundingBox();
|
||||
expect(pane.y).toBeGreaterThanOrEqual(bounds.y);
|
||||
expect(pane.y+pane.height).toBeLessThanOrEqual(bounds.y+bounds.height);
|
||||
}
|
||||
expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
|
||||
const pair={id:4,positive_text:'Library positive',negative_text:'Library negative',category:'Portrait',tags:['studio']};
|
||||
test.beforeEach(async({page,browserName})=>{
|
||||
await startCoverage(page,browserName);
|
||||
page.pairErrors=[];page.on('pageerror',error=>page.pairErrors.push(error.message));
|
||||
await page.route('**/scripts/app.js',route=>route.fulfill({contentType:'text/javascript',body:'export const app=window.fixtureApp;'}));
|
||||
await page.route('**/scripts/api.js',route=>route.fulfill({contentType:'text/javascript',body:'export const api={fileURL:path=>path,queuePrompt:async(...args)=>args};'}));
|
||||
await page.route('**/extensions/promptmanager/prompt_manager.js',route=>route.fulfill({contentType:'text/javascript',body:readFileSync(new URL('../../web/comfy/prompt_manager.js',import.meta.url),'utf8')}));
|
||||
await page.route('**/prompt_manager/pairs?**',route=>route.fulfill({json:{success:true,pairs:[pair],total:1}}));
|
||||
await page.goto('/prompt_manager/test');
|
||||
await page.evaluate(async()=>{
|
||||
document.body.replaceChildren();
|
||||
const settings=new EventTarget();settings.getSettingValue=()=> 'dark';
|
||||
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings},graph:{_nodes:[],setDirtyCanvas(){}},canvas:{},graphToPrompt:async()=>({})};
|
||||
await import('/extensions/promptmanager/prompt_manager.js');
|
||||
class Node {
|
||||
constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=['text','negative_text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[1100,700];}
|
||||
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
|
||||
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.cssText='height:700px;width:1100px;max-width:100%';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
|
||||
setSize(size){this.size=size;}setDirtyCanvas(){}
|
||||
serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
|
||||
configure(data){this.widgets.forEach((widget,index)=>widget.value=data.widgets_values[index]);this.properties={...data.properties};}
|
||||
}
|
||||
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:'PromptManagerPair'},fixtureApp);
|
||||
window.node=new Node();node.onNodeCreated?.();fixtureApp.graph._nodes=[node];
|
||||
});
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.pairErrors).toEqual([]);});
|
||||
|
||||
async function selectPair(page){await page.getByRole('button',{name:'Recent',exact:true}).click();await page.locator('.pm-editor-result').click();}
|
||||
async function draft(page,positive='Draft positive',negative='Draft negative'){await page.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(positive);await page.getByRole('textbox',{name:'Negative prompt',exact:true}).fill(negative);}
|
||||
|
||||
test('paired editor previews both texts before explicitly replacing both',async({page})=>{
|
||||
await expect(page.locator('.pm-pair')).toBeVisible();
|
||||
await draft(page);
|
||||
await selectPair(page);
|
||||
await expect(page.locator('.pm-editor-selection')).toContainText('Library positive');
|
||||
await expect(page.locator('.pm-editor-selection')).toContainText('Library negative');
|
||||
await expect(page.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Draft positive');
|
||||
await expect(page.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue('Draft negative');
|
||||
await page.getByRole('switch',{name:'Load metadata'}).uncheck();
|
||||
await page.getByRole('button',{name:'Use pair',exact:true}).click();
|
||||
await expect(page.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue(pair.positive_text);
|
||||
await expect(page.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(pair.negative_text);
|
||||
expect(await page.evaluate(()=>Object.fromEntries(node.widgets.map(w=>[w.name,w.value])))).toMatchObject({text:pair.positive_text,negative_text:pair.negative_text,category:'',tags:''});
|
||||
await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);});
|
||||
await expect(page.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(pair.negative_text);
|
||||
});
|
||||
|
||||
test('paired editor sizes both editors equally and composes positive text only',async({page},testInfo)=>{
|
||||
await draft(page);
|
||||
const positive=page.getByRole('textbox',{name:'Positive prompt',exact:true});const negative=page.getByRole('textbox',{name:'Negative prompt',exact:true});
|
||||
const [p,n]=await Promise.all([positive.boundingBox(),negative.boundingBox()]);expect(Math.abs(p.height-n.height)).toBeLessThan(1);expect(Math.abs(p.width-n.width)).toBeLessThan(1);
|
||||
await page.getByText('Composition · prepend / append',{exact:true}).click();
|
||||
await page.getByRole('textbox',{name:'Prepend',exact:true}).fill('trigger');
|
||||
await page.getByRole('textbox',{name:'Append',exact:true}).fill('detail');
|
||||
await page.getByText('Composed text preview',{exact:true}).click();
|
||||
await expect(page.locator('[data-composed-preview]')).toHaveText('trigger Draft positive detail');
|
||||
await expect(negative).toHaveValue('Draft negative');
|
||||
await page.screenshot({path:testInfo.outputPath('pair-editor.png')});
|
||||
});
|
||||
|
||||
for(const field of ['text','negative_text']) test(`paired loading is atomic when ${field} is connected`,async({page})=>{
|
||||
await draft(page);
|
||||
await selectPair(page);
|
||||
await page.evaluate(field=>{node.inputs.push({name:field,link:8});node.onConnectionsChange();},field);
|
||||
await expect(page.getByRole('button',{name:'Use pair',exact:true})).toBeDisabled();
|
||||
await page.getByRole('button',{name:'Use pair',exact:true}).evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));
|
||||
expect(await page.evaluate(()=>Object.fromEntries(node.widgets.map(w=>[w.name,w.value])))).toMatchObject({text:'Draft positive',negative_text:'Draft negative'});
|
||||
await page.evaluate(()=>{node.inputs=node.inputs.filter(input=>input.link!==8);node.onConnectionsChange();});
|
||||
await expect(page.getByRole('button',{name:'Use pair',exact:true})).toBeEnabled();
|
||||
});
|
||||
|
||||
test('save pair accepts empty negatives, recovers after errors and never clears the draft',async({page})=>{
|
||||
let attempts=0;const bodies=[];
|
||||
await page.route('**/prompt_manager/pairs',route=>{bodies.push(route.request().postDataJSON());return ++attempts===1?route.fulfill({status:503,json:{success:false,error:'Temporary save error'}}):route.fulfill({json:{success:true,pair:{...pair,negative_text:''}}});});
|
||||
await draft(page,'Save this','');
|
||||
await page.getByRole('button',{name:'Save pair',exact:true}).click();
|
||||
await expect(page.getByRole('alert')).toContainText('Temporary save error');
|
||||
await expect(page.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Save this');
|
||||
await page.getByRole('button',{name:'Save pair',exact:true}).click();
|
||||
await expect(page.getByRole('status').filter({hasText:'Pair saved'})).toBeVisible();
|
||||
expect(bodies).toEqual([{positive_text:'Save this',negative_text:'',category:'',tags:[]},{positive_text:'Save this',negative_text:'',category:'',tags:[]}]);
|
||||
});
|
||||
|
||||
test('pair filters use repeated tags and removal cleans up',async({page})=>{
|
||||
const urls=[];await page.route('**/prompt_manager/pairs?**',route=>{urls.push(new URL(route.request().url()));return route.fulfill({json:{success:true,pairs:[pair]}});});
|
||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('portrait tag:studio');
|
||||
await page.getByLabel('Filter category',{exact:true}).fill('Portrait');
|
||||
await page.getByLabel('Filter tags',{exact:true}).fill('film, studio, ');
|
||||
await page.getByRole('button',{name:'Search',exact:true}).click();await expect(page.locator('.pm-editor-result')).toBeVisible();
|
||||
expect(urls[0].searchParams.get('category')).toBe('Portrait');expect(urls[0].searchParams.getAll('tag')).toEqual(['studio','film']);
|
||||
await page.evaluate(()=>node.onRemoved());await expect(page.locator('.ca-completions')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('validating and saving prevents duplicate submissions while preserving pair metadata and drafts',async({page})=>{
|
||||
let release;let requests=0;
|
||||
await page.route('**/prompt_manager/pairs',async route=>{requests++;await new Promise(resolve=>release=resolve);await route.fulfill({json:{success:true,pair:{...pair,category:'Original category',tags:['original-tag']}}});});
|
||||
await page.getByRole('button',{name:'Save pair',exact:true}).click();
|
||||
await expect(page.getByRole('alert')).toContainText('positive');expect(requests).toBe(0);
|
||||
await draft(page);
|
||||
await page.getByRole('button',{name:'Save pair',exact:true}).click();
|
||||
await expect(page.getByRole('button',{name:'Save pair',exact:true})).toBeDisabled();
|
||||
await page.getByRole('button',{name:'Save pair',exact:true}).evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));
|
||||
expect(requests).toBe(1);release();
|
||||
await expect(page.getByRole('status').filter({hasText:'Pair saved'})).toBeVisible();
|
||||
expect(await page.evaluate(()=>Object.fromEntries(node.widgets.map(w=>[w.name,w.value])))).toMatchObject({text:'Draft positive',negative_text:'Draft negative',category:'',tags:''});
|
||||
});
|
||||
|
||||
test('a superseded library response never replaces the latest pair selection',async({page})=>{
|
||||
let release;let started=false;
|
||||
await page.route('**/prompt_manager/pairs?**',async route=>{
|
||||
if(new URL(route.request().url()).searchParams.get('text')==='old'){started=true;await new Promise(resolve=>release=resolve);await route.fulfill({json:{success:true,pairs:[{...pair,positive_text:'Stale positive'}]}}).catch(()=>{});}
|
||||
else await route.fulfill({json:{success:true,pairs:[pair]}});
|
||||
});
|
||||
const search=page.getByRole('searchbox',{name:'Search saved prompts'});
|
||||
await search.fill('old');await page.getByRole('button',{name:'Search',exact:true}).click();await expect.poll(()=>started).toBe(true);
|
||||
await search.fill('new');await page.getByRole('button',{name:'Search',exact:true}).click();
|
||||
await expect(page.locator('.pm-editor-result')).toContainText('Library positive');release();
|
||||
await expect(page.locator('.pm-editor-result')).not.toContainText('Stale positive');
|
||||
});
|
||||
|
||||
test('queue hook synchronizes both editors, preserves links and forwards branch execution arguments',async({page})=>{
|
||||
await draft(page);
|
||||
const args=await page.evaluate(async()=>{
|
||||
await fixtureApp.extension.setup();
|
||||
const {api}=await import('/scripts/api.js');
|
||||
const output={1:{inputs:{text:'stale',negative_text:'stale'}}};
|
||||
const first=await api.queuePrompt(7,{output,workflow:{name:'workflow'}},['branch'],{preview:'latent'});
|
||||
node.inputs.push({name:'negative_text',link:17});
|
||||
const linked={1:{inputs:{text:'old',negative_text:['17',0]}}};
|
||||
const second=await api.queuePrompt(8,{output:linked,workflow:{}},['another']);
|
||||
return {first,second};
|
||||
});
|
||||
expect(args.first).toEqual([7,{output:{1:{inputs:{text:'Draft positive',negative_text:'Draft negative'}}},workflow:{name:'workflow'}},['branch'],{preview:'latent'}]);
|
||||
expect(args.second[1].output[1].inputs).toEqual({text:'Draft positive',negative_text:['17',0]});
|
||||
});
|
||||
|
||||
test('paired editors stack at narrow widths while keeping equal editing areas',async({page})=>{
|
||||
await page.locator('.fixture-dom-widget').evaluate(host=>host.style.width='560px');
|
||||
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>page.getByRole('textbox',{name,exact:true}).boundingBox()));expect(n.y).toBeGreaterThanOrEqual(p.y+p.height);expect(Math.abs(p.width-n.width)).toBeLessThan(1);expect(Math.abs(p.height-n.height)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test('removal aborts an in-flight pair save and restores host-owned textarea references',async({page})=>{
|
||||
await draft(page);
|
||||
const state=await page.evaluate(async()=>{
|
||||
const previous=window.fetch;let signal;
|
||||
window.fetch=(url,options)=>{if(String(url).endsWith('/pairs')){signal=options.signal;return new Promise((resolve,reject)=>signal.addEventListener('abort',()=>reject(new DOMException('Aborted','AbortError')),{once:true}));}return previous(url,options);};
|
||||
const visible=node.widgets.find(w=>w.name==='negative_text').inputEl;
|
||||
[...document.querySelectorAll('button')].find(button=>button.textContent==='Save pair').click();
|
||||
node.onRemoved();
|
||||
return {aborted:signal.aborted,restored:node.widgets.find(w=>w.name==='negative_text').inputEl!==visible};
|
||||
});
|
||||
expect(state).toEqual({aborted:true,restored:true});
|
||||
});
|
||||
|
||||
test('actual pair saves coexist with resetting the disposable browser fixture',async({page,request})=>{
|
||||
await draft(page,'Actual browser pair','Actual negative');await page.getByRole('button',{name:'Save pair',exact:true}).click();await expect(page.getByRole('status').filter({hasText:'Pair saved'})).toBeVisible();
|
||||
const response=await request.get('/prompt_manager/pairs?text=Actual%20browser%20pair');expect(response.ok()).toBe(true);expect((await response.json()).pairs).toEqual(expect.arrayContaining([expect.objectContaining({positive_text:'Actual browser pair',negative_text:'Actual negative'})]));
|
||||
const reset=await request.post('/__test__/reset');expect(reset.ok()).toBe(true);
|
||||
const after=await request.get('/prompt_manager/pairs?text=Actual%20browser%20pair');expect((await after.json()).pairs).toHaveLength(0);
|
||||
});
|
||||
|
||||
for(const field of ['category','tags'])test(`manual pair save waits for execution when ${field} comes from an upstream link`,async({page})=>{
|
||||
let requests=0;await page.route('**/prompt_manager/pairs',route=>{requests++;return route.fulfill({json:{success:true,pair}});});await draft(page);
|
||||
await page.evaluate(field=>{node.inputs.push({name:field,link:19});node.onConnectionsChange();},field);
|
||||
const save=page.getByRole('button',{name:'Save pair',exact:true});await expect(save).toBeDisabled();await expect(page.getByRole('status').filter({hasText:'Run the node to save connected values.'})).toBeVisible();
|
||||
await save.evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));expect(requests).toBe(0);
|
||||
await page.evaluate(()=>{node.inputs=node.inputs.filter(input=>input.link!==19);node.onConnectionsChange();});await expect(save).toBeEnabled();await save.click();await expect.poll(()=>requests).toBe(1);
|
||||
});
|
||||
|
||||
for(const field of ['positive','negative','category','tags'])test(`save completion identifies an earlier draft after editing ${field}`,async({page})=>{
|
||||
let release;await page.route('**/prompt_manager/pairs',async route=>{await new Promise(resolve=>release=resolve);await route.fulfill({json:{success:true,pair}});});await draft(page);
|
||||
await page.getByText('Metadata · category / tags',{exact:true}).click();await page.getByRole('button',{name:'Save pair',exact:true}).click();await expect.poll(()=>Boolean(release)).toBe(true);
|
||||
const input=field==='positive'?page.getByRole('textbox',{name:'Positive prompt',exact:true}):field==='negative'?page.getByRole('textbox',{name:'Negative prompt',exact:true}):page.getByRole('combobox',{name:field==='category'?'Category':'Tags',exact:true});
|
||||
await input.fill(`Changed ${field}`);release();await expect(page.getByRole('status').filter({hasText:'Earlier draft saved. Current changes are not saved yet.'})).toBeVisible();await expect(input).toHaveValue(`Changed ${field}`);
|
||||
});
|
||||
|
||||
test('pair library rows distinguish negative variants and explicitly show empty negatives as text',async({page})=>{
|
||||
const longNegative='Variant one '+ 'x'.repeat(200);
|
||||
await page.route('**/prompt_manager/pairs?**',route=>route.fulfill({json:{success:true,pairs:[{...pair,negative_text:longNegative},{...pair,id:5,negative_text:'Variant two'},{...pair,id:6,negative_text:''},{...pair,id:7,negative_text:'<img src=x onerror=alert(1)>'}]}}));
|
||||
await page.getByRole('button',{name:'Recent',exact:true}).click();const rows=page.locator('.pm-editor-result');await expect(rows).toHaveCount(4);await expect(rows.nth(0)).toContainText('Negative: Variant one');await expect(rows.nth(1)).toContainText('Negative: Variant two');await expect(rows.nth(2)).toContainText('Negative: (empty)');await expect(rows.nth(3)).toContainText('Negative: <img src=x onerror=alert(1)>');await expect(rows.locator('img')).toHaveCount(0);await expect(rows.nth(0)).not.toContainText('x'.repeat(180));await expect(rows.nth(0).locator('span').nth(1)).toHaveAttribute('title',longNegative);
|
||||
});
|
||||
@@ -80,7 +80,15 @@ def create_app(root: Path) -> web.Application:
|
||||
|
||||
def seed(extra_images: int = 0) -> None:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in ("generated_images", "prompt_tags", "tags", "prompts"):
|
||||
for table in (
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompts",
|
||||
):
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
shutil.rmtree(root / name)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const statistic = (page, label) => page.locator('#workspace-main dt')
|
||||
.filter({ hasText: new RegExp(`^${label}$`) }).locator('..').locator('dd');
|
||||
const routes = [
|
||||
['overview', 'Overview', null],
|
||||
['images', 'Images', '/prompt_manager/gallery'],
|
||||
['library', 'Prompt library', '/prompt_manager/admin'],
|
||||
['tags', 'Tags & categories', '/prompt_manager/admin#/tags'],
|
||||
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
|
||||
['system', 'System', '/prompt_manager/admin'],
|
||||
];
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.workspaceErrors = [];
|
||||
page.on('pageerror', error => page.workspaceErrors.push(error.message));
|
||||
page.on('response', response => {
|
||||
if (response.status() >= 400 && /\/prompt_manager\/(js|lib)\//.test(response.url())) {
|
||||
page.workspaceErrors.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.workspaceErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('workspace starts with real persisted statistics without mutating data', async ({ page, request }) => {
|
||||
const before = await (await request.get('/prompt_manager/stats')).json();
|
||||
const mutations = [];
|
||||
page.on('request', request => {
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) mutations.push(request.url());
|
||||
});
|
||||
await page.goto(workspace);
|
||||
await expect(page).toHaveTitle(/Prompt Manager/);
|
||||
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })).toBeVisible();
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
|
||||
for (const [label, key] of [['Prompts', 'total_prompts'], ['Images', 'total_images'], ['Tags', 'total_tags']]) {
|
||||
await expect(statistic(page, label)).toHaveText(String(before.stats[key]));
|
||||
}
|
||||
expect(before.stats.total_prompts).toBe(2);
|
||||
expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(before.stats);
|
||||
expect(mutations).toEqual([]);
|
||||
await expect(page.locator('iframe')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('all shell destinations deep-link, reload and expose working existing applications', async ({ page, request }) => {
|
||||
for (const [route, label, legacy] of routes) {
|
||||
await page.goto(`${workspace}#/${route}`);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: label, exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
if (legacy) {
|
||||
await expect(main.locator(`a[href="${legacy}"]`)).toBeVisible();
|
||||
await expect(await request.get(legacy.split('#')[0])).toBeOK();
|
||||
}
|
||||
await page.reload();
|
||||
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
const images = navigation.getByRole('link', { name: 'Images', exact: true });
|
||||
await images.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await navigation.getByRole('link', { name: 'Prompt library', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.goBack();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await page.goForward();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.goto(`${workspace}#/not-a-page`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(navigation.getByRole('link', { name: 'Overview', exact: true }))
|
||||
.toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('skip to content focuses the current view without changing its route', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByRole('link', { name: 'Skip to content', exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page).toHaveURL(`${workspace}#/library`);
|
||||
await expect(page.locator('#workspace-main')).toBeFocused();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
});
|
||||
|
||||
test('returning to overview refreshes persisted counts without duplicating navigation', async ({ page, request }) => {
|
||||
await page.goto(workspace);
|
||||
const prompts = statistic(page, 'Prompts');
|
||||
await expect(prompts).toHaveText('2');
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await expect(await request.post('/prompt_manager/save', { data: { text: 'A new persisted workspace prompt' } })).toBeOK();
|
||||
await navigation.getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(prompts).toHaveText('3');
|
||||
await expect(navigation).toHaveCount(1);
|
||||
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
|
||||
});
|
||||
|
||||
test('appearance follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => {
|
||||
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ json: 'light' }));
|
||||
await page.goto(workspace);
|
||||
const theme = page.locator('[data-ca-theme]').first();
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
const appearance = page.getByRole('combobox', { name: 'Appearance', exact: true });
|
||||
await appearance.selectOption({ label: 'Dark' });
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
|
||||
await page.reload();
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
|
||||
await expect(appearance).toHaveValue('dark');
|
||||
await appearance.selectOption({ label: 'Light' });
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
await appearance.selectOption({ label: 'Follow ComfyUI' });
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
});
|
||||
|
||||
test('unavailable host palette falls back to system appearance and tracks system changes', async ({ page }) => {
|
||||
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ status: 503, json: {} }));
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.goto(workspace);
|
||||
const theme = page.locator('[data-ca-theme]').first();
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await expect(theme).toHaveAttribute('data-ca-theme', 'dark');
|
||||
});
|
||||
|
||||
test('overview reports loading, offers retry after failure and renders real empty statistics', async ({ page }) => {
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
await page.route('**/prompt_manager/stats', async route => {
|
||||
await pending;
|
||||
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
|
||||
});
|
||||
await page.goto(workspace);
|
||||
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
|
||||
release();
|
||||
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
|
||||
await page.unroute('**/prompt_manager/stats');
|
||||
await page.route('**/prompt_manager/stats', route => route.fulfill({
|
||||
json: { success: true, stats: { total_prompts: 0, total_images: 0, total_tags: 0 } },
|
||||
}));
|
||||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main').getByRole('alert')).toHaveCount(0);
|
||||
for (const label of ['Prompts', 'Images', 'Tags']) {
|
||||
await expect(statistic(page, label)).toHaveText('0');
|
||||
}
|
||||
});
|
||||
|
||||
test('small screens keep navigation and appearance accessible without horizontal page overflow', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto(workspace);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
for (const [, label] of routes) {
|
||||
await navigation.getByRole('link', { name: label, exact: true }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText(label);
|
||||
}
|
||||
await expect(page.getByRole('combobox', { name: 'Appearance', exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
test('leaving a loading overview prevents its delayed result from replacing the new view', async ({ page }) => {
|
||||
let release;
|
||||
let completed;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
const finished = new Promise(resolve => { completed = resolve; });
|
||||
await page.route('**/prompt_manager/stats', async route => {
|
||||
await pending;
|
||||
await route.fulfill({ json: {
|
||||
success: true, stats: { total_prompts: 999, total_images: 888, total_tags: 777 },
|
||||
} }).catch(() => {});
|
||||
completed();
|
||||
});
|
||||
await page.goto(workspace);
|
||||
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
release();
|
||||
await finished;
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(page.locator('#workspace-main')).not.toContainText('999');
|
||||
await expect(page.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('cached-page restoration remounts one working shell and refreshes its data', async ({ page }) => {
|
||||
let reads = 0;
|
||||
page.on('request', request => {
|
||||
if (new URL(request.url()).pathname === '/prompt_manager/stats') reads++;
|
||||
});
|
||||
await page.goto(workspace);
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
for (let restore = 0; restore < 2; restore++) {
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }));
|
||||
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
|
||||
});
|
||||
await expect(statistic(page, 'Prompts')).toHaveText('2');
|
||||
}
|
||||
expect(reads).toBe(3);
|
||||
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
|
||||
await expect(navigation.getByRole('link')).toHaveCount(routes.length);
|
||||
await navigation.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await page.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
|
||||
await expect(page.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
});
|
||||
@@ -1,19 +1,21 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { installHostHooks } from './host-fixture.mjs';
|
||||
|
||||
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptSearchList', 'PromptManagerFocused']);
|
||||
|
||||
async function addFocused(page) {
|
||||
return page.evaluate(async () => {
|
||||
async function addEditor(page, type) {
|
||||
await page.waitForFunction(type => Boolean(globalThis.LiteGraph?.registered_node_types?.[type]), type);
|
||||
return page.evaluate(async type => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('PromptManagerFocused');
|
||||
if (!node) throw new Error('PromptManagerFocused must be registered by the real host');
|
||||
const node = LiteGraph.createNode(type);
|
||||
if (!node) throw new Error(`${type} must be registered by the real host`);
|
||||
app.graph.add(node);
|
||||
node.setSize([680, 1050]);
|
||||
node.setSize([type === 'PromptManagerSplit' ? 1100 : 680, 1050]);
|
||||
app.canvas.ds.scale = .8;
|
||||
app.canvas.centerOnNode(node);
|
||||
return node.id;
|
||||
});
|
||||
}, type);
|
||||
}
|
||||
|
||||
async function model(page, id) {
|
||||
@@ -29,19 +31,26 @@ async function seed(request, text, category = 'Focused host tests', tags = ['foc
|
||||
expect((await response.json()).success).toBe(true);
|
||||
}
|
||||
|
||||
async function showLibrary(page) {
|
||||
const button = page.locator('.pm-focused').getByRole('button', { name: 'Browse library', exact: true });
|
||||
if (await button.count()) await button.click();
|
||||
}
|
||||
|
||||
async function browse(page, text) {
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
||||
await showLibrary(page);
|
||||
await root.getByRole('button', { name: 'Recent', exact: true }).click();
|
||||
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
|
||||
return root;
|
||||
}
|
||||
|
||||
test('Focused definition preserves Classic encoding and exposes composition STRING sockets', async ({ request }) => {
|
||||
for (const [label, type] of [['Focused', 'PromptManagerFocused'], ['Split', 'PromptManagerSplit']]) {
|
||||
test.describe(label, () => {
|
||||
test(`${label} definition preserves Classic encoding and exposes composition STRING sockets`, async ({ request }) => {
|
||||
const definitions = await (await request.get('/object_info')).json();
|
||||
const focused = definitions.PromptManagerFocused;
|
||||
const focused = definitions[type];
|
||||
expect(focused).toBeDefined();
|
||||
expect(focused.display_name).toBe('Prompt Manager (Focused)');
|
||||
expect(focused.display_name).toBe(`Prompt Manager (${label})`);
|
||||
expect(focused.output).toEqual(definitions.PromptManager.output);
|
||||
expect(focused.input.required).toEqual(definitions.PromptManager.input.required);
|
||||
for (const name of ['prepend_text', 'append_text']) {
|
||||
@@ -50,10 +59,10 @@ test('Focused definition preserves Classic encoding and exposes composition STRI
|
||||
}
|
||||
});
|
||||
|
||||
test('Focused preview, explicit load, composition, renderer changes and workflow reload preserve state', async ({ page, request }, testInfo) => {
|
||||
test(`${label} preview, explicit load, composition, renderer changes and workflow reload preserve state`, async ({ page, request }, testInfo) => {
|
||||
const text = `Focused selected ${testInfo.project.name}`;
|
||||
await seed(request, text);
|
||||
const id = await addFocused(page);
|
||||
const id = await addEditor(page, type);
|
||||
const root = page.locator('.pm-focused');
|
||||
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
|
||||
await expect(root).toBeVisible();
|
||||
@@ -92,20 +101,20 @@ test('Focused preview, explicit load, composition, renderer changes and workflow
|
||||
expect(queued).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host', prepend_text: 'LoRA trigger', append_text: 'high detail' });
|
||||
await expect(editor).toHaveValue(text);
|
||||
await expect.poll(() => model(page, id)).toMatchObject({ text, prepend_text: 'LoRA trigger', append_text: 'high detail' });
|
||||
await page.screenshot({ path: testInfo.outputPath('focused-reloaded.png') });
|
||||
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-reloaded.png`) });
|
||||
await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id);
|
||||
await expect(root).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Focused search preview respects metadata opt-out', async ({ page, request }, testInfo) => {
|
||||
test(`${label} search preview respects metadata opt-out`, async ({ page, request }, testInfo) => {
|
||||
const text = `Focused search ${testInfo.project.name}`;
|
||||
await seed(request, text, 'Incoming category', ['incoming-tag']);
|
||||
const id = await addFocused(page);
|
||||
const id = await addEditor(page, type);
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
||||
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Keep category');
|
||||
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('keep-tag');
|
||||
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
||||
await showLibrary(page);
|
||||
await root.getByRole('searchbox', { name: 'Search saved prompts', exact: true }).fill(text);
|
||||
await root.getByRole('button', { name: 'Search', exact: true }).click();
|
||||
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
|
||||
@@ -114,10 +123,10 @@ test('Focused search preview respects metadata opt-out', async ({ page, request
|
||||
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Keep category', tags: 'keep-tag' });
|
||||
});
|
||||
|
||||
test('Focused connected STRING inputs remain linked through UI actions and reload', async ({ page, request }, testInfo) => {
|
||||
test(`${label} connected STRING inputs remain linked through UI actions and reload`, async ({ page, request }, testInfo) => {
|
||||
const text = `Connected library ${testInfo.project.name}`;
|
||||
await seed(request, text);
|
||||
const id = await addFocused(page);
|
||||
const id = await addEditor(page, type);
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Local main');
|
||||
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
||||
@@ -179,11 +188,11 @@ test('Focused connected STRING inputs remain linked through UI actions and reloa
|
||||
await expect(input).toBeEditable();
|
||||
await expect(input).toHaveValue(value);
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath('focused-disconnected.png') });
|
||||
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-disconnected.png`) });
|
||||
});
|
||||
|
||||
test('Focused follows actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
|
||||
await addFocused(page);
|
||||
test(`${label} follows actual ComfyUI light and dark palettes`, async ({ page }, testInfo) => {
|
||||
await addEditor(page, type);
|
||||
for (const palette of ['light', 'dark']) {
|
||||
await page.evaluate(async palette => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
@@ -191,30 +200,35 @@ test('Focused follows actual ComfyUI light and dark palettes', async ({ page },
|
||||
}, palette);
|
||||
const root = page.locator('.pm-focused');
|
||||
await expect(root).toHaveAttribute('data-ca-theme', palette);
|
||||
await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||
await page.screenshot({ path: testInfo.outputPath(`focused-${palette}.png`) });
|
||||
if (label === 'Split') {
|
||||
await expect(root.getByRole('button', { name: /^(Browse|Hide) library$/ })).toHaveCount(0);
|
||||
await expect(root.getByRole('button', { name: 'Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||
} else {
|
||||
await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-${palette}.png`) });
|
||||
}
|
||||
});
|
||||
|
||||
test('Focused Web UI button opens actual standalone pages', async ({ page }) => {
|
||||
await addFocused(page);
|
||||
test(`${label} Web UI button opens actual standalone pages`, async ({ page }) => {
|
||||
await addEditor(page, type);
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
|
||||
await expect(popup.locator('body')).toContainText('Prompt');
|
||||
await expect(page.locator('.pm-focused').getByRole('status')).toHaveText('Web UI opened.');
|
||||
await expect(page.locator('.pm-focused').getByRole('status').filter({ hasText: 'Web UI opened.' })).toHaveText('Web UI opened.');
|
||||
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
|
||||
await expect(popup.locator('#addPromptBtn')).toBeVisible();
|
||||
await popup.close();
|
||||
});
|
||||
|
||||
test('Focused metadata completion uses saved categories and tags across renderers and reload', async ({ page, request }, testInfo) => {
|
||||
test(`${label} metadata completion uses saved categories and tags across renderers and reload`, async ({ page, request }, testInfo) => {
|
||||
const category = `Host completion category ${testInfo.project.name}`;
|
||||
const tag = `host-completion-${testInfo.project.name}`;
|
||||
await seed(request, `Saved autocomplete ${testInfo.project.name}`, category, [tag]);
|
||||
const id = await addFocused(page);
|
||||
const id = await addEditor(page, type);
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Autocomplete workflow');
|
||||
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
||||
@@ -244,7 +258,7 @@ test('Focused metadata completion uses saved categories and tags across renderer
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height);
|
||||
await page.screenshot({ path: testInfo.outputPath(`focused-metadata-${palette}.png`) });
|
||||
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-metadata-${palette}.png`) });
|
||||
await option.click();
|
||||
await expect(tagInput).toHaveValue(`keep, ${tag}, `);
|
||||
}
|
||||
@@ -279,20 +293,20 @@ test('Focused metadata completion uses saved categories and tags across renderer
|
||||
await expect(page.getByRole('listbox', { name: 'Saved tags', includeHidden: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Focused library autocomplete filters saved prompts without editing draft metadata', async ({ page, request }, testInfo) => {
|
||||
test(`${label} library autocomplete filters saved prompts without editing draft metadata`, async ({ page, request }, testInfo) => {
|
||||
const category = `Library category ${testInfo.project.name}`;
|
||||
const tag = `library-filter-${testInfo.project.name}`;
|
||||
const matchingText = `Matching library prompt ${testInfo.project.name}`;
|
||||
await seed(request, matchingText, category, [tag]);
|
||||
await seed(request, `Wrong category ${testInfo.project.name}`, `Other category ${testInfo.project.name}`, [tag]);
|
||||
await seed(request, `Wrong tag ${testInfo.project.name}`, category, [`other-filter-${testInfo.project.name}`]);
|
||||
const id = await addFocused(page);
|
||||
const id = await addEditor(page, type);
|
||||
const root = page.locator('.pm-focused');
|
||||
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Keep my draft');
|
||||
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
||||
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Draft category');
|
||||
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('draft-tag');
|
||||
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
||||
await showLibrary(page);
|
||||
const filterCategory = root.getByRole('combobox', { name: 'Filter category', exact: true });
|
||||
const filterTags = root.getByRole('combobox', { name: 'Filter tags', exact: true });
|
||||
await filterCategory.fill(category.slice(0, -3));
|
||||
@@ -308,7 +322,7 @@ test('Focused library autocomplete filters saved prompts without editing draft m
|
||||
await filterTags.fill(tag.slice(0, -3));
|
||||
const option = page.getByRole('listbox', { name: 'Saved tags', exact: true }).getByRole('option', { name: tag, exact: true });
|
||||
await expect(option).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath(`focused-library-filters-${palette}.png`) });
|
||||
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-library-filters-${palette}.png`) });
|
||||
await option.click();
|
||||
await expect(filterTags).toHaveValue(`${tag}, `);
|
||||
}
|
||||
@@ -344,3 +358,84 @@ test('Focused library autocomplete filters saved prompts without editing draft m
|
||||
await expect(results.first()).toContainText(matchingText);
|
||||
await expect.poll(() => model(page, id)).toMatchObject(draft);
|
||||
});
|
||||
|
||||
if (label === 'Split') {
|
||||
test('Split keeps its library visible beside the editor and stacks narrow nodes', async ({ page, request }, testInfo) => {
|
||||
for (let index = 0; index < 21; index++) {
|
||||
await seed(request, `Split layout recent ${testInfo.project.name} ${index}`);
|
||||
}
|
||||
const id = await addEditor(page, type);
|
||||
const root = page.locator('.pm-split');
|
||||
await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph.getNodeById(id);
|
||||
node.setSize([900, 650]);
|
||||
app.canvas.centerOnNode(node);
|
||||
}, id);
|
||||
await expect(root).toHaveAttribute('aria-label', 'Split prompt editor');
|
||||
const savedProperties = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return app.graph.getNodeById(id).serialize().properties;
|
||||
}, id);
|
||||
expect(savedProperties).toHaveProperty('splitUI');
|
||||
expect(savedProperties).not.toHaveProperty('focusedUI');
|
||||
await expect(root.getByRole('button', { name: /^(Browse|Hide) library$/ })).toHaveCount(0);
|
||||
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
|
||||
const library = root.getByRole('region', { name: 'Prompt library', exact: true });
|
||||
await expect(library).toBeVisible();
|
||||
const captureGeometry = async phase => {
|
||||
const geometry = await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph.getNodeById(id);
|
||||
const root = node._focusedView.element;
|
||||
const bounds = selector => root.querySelector(selector)?.getBoundingClientRect().toJSON();
|
||||
const ancestors = [];
|
||||
for (let element = root; element && element !== document.body; element = element.parentElement) {
|
||||
const style = getComputedStyle(element);
|
||||
ancestors.push({ tag: element.tagName, className: element.className, bounds: element.getBoundingClientRect().toJSON(), inlineStyle: element.style.cssText, computed: Object.fromEntries(['height', 'min-height', 'max-height', 'display', 'overflow', 'flex', 'contain', 'position'].map(name => [name, style.getPropertyValue(name)])) });
|
||||
}
|
||||
return { nodeSize: [...node.size], ancestors, root: root.getBoundingClientRect().toJSON(), library: bounds('.pm-editor-library'), results: bounds('.pm-editor-results'), use: bounds('.pm-editor-selection button'), footer: bounds(':scope > .pm-editor-status') };
|
||||
}, id);
|
||||
const path = testInfo.outputPath(`split-${phase}-geometry.json`);
|
||||
await writeFile(path, JSON.stringify(geometry, null, 2));
|
||||
await testInfo.attach(`split-${phase}-geometry.json`, { path, contentType: 'application/json' });
|
||||
return geometry;
|
||||
};
|
||||
const beforeRecent = await captureGeometry('before-recent');
|
||||
await root.getByRole('button', { name: 'Recent', exact: true }).click();
|
||||
const results = library.locator('.pm-editor-results').getByRole('button');
|
||||
await expect(results).toHaveCount(20);
|
||||
const promptText = results.first().locator('span').first();
|
||||
const selectedText = await promptText.innerText();
|
||||
await results.first().click();
|
||||
const use = root.getByRole('button', { name: 'Use prompt', exact: true });
|
||||
const afterSelection = await captureGeometry('after-selection');
|
||||
await page.screenshot({ path: testInfo.outputPath('split-default-selected.png') });
|
||||
expect.soft((await promptText.boundingBox())?.height || 0).toBeGreaterThan(10);
|
||||
expect.soft(afterSelection.root.height).toBeLessThanOrEqual(beforeRecent.root.height + 1);
|
||||
await use.click();
|
||||
await expect(editor).toHaveValue(selectedText);
|
||||
await expect.poll(async () => {
|
||||
const main = await editor.boundingBox();
|
||||
const browser = await library.boundingBox();
|
||||
return browser.x >= main.x + main.width;
|
||||
}).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath('split-wide.png') });
|
||||
await page.evaluate(async id => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph.getNodeById(id);
|
||||
node.setSize([480, 1050]);
|
||||
app.canvas.centerOnNode(node);
|
||||
}, id);
|
||||
await expect.poll(async () => {
|
||||
const main = await editor.boundingBox();
|
||||
const browser = await library.boundingBox();
|
||||
return browser.y >= main.y + main.height;
|
||||
}).toBe(true);
|
||||
await expect(library).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('split-narrow.png') });
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {installHostHooks} from './host-fixture.mjs';
|
||||
|
||||
installHostHooks(test,['PromptManagerPair']);
|
||||
async function addPair(page){return page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);return node.id;});}
|
||||
async function seed(request,positive,negative){const response=await request.post('/prompt_manager/pairs',{data:{positive_text:positive,negative_text:negative,category:'Paired host tests',tags:['pair-host']}});expect(response.ok()).toBe(true);return (await response.json()).pair;}
|
||||
async function values(page,id){return page.evaluate(async id=>{const {app}=await import('/scripts/app.js');return Object.fromEntries(app.graph.getNodeById(id).widgets.map(w=>[w.name,w.value]));},id);}
|
||||
async function select(page,text){const root=page.locator('.pm-pair');await root.getByRole('button',{name:'Recent',exact:true}).click();await root.locator('.pm-editor-result').filter({has:page.getByText(text,{exact:true})}).click();return root;}
|
||||
|
||||
test('paired definition exposes both conditioning/text outputs and positive-only composition',async({request})=>{
|
||||
const definitions=await(await request.get('/object_info')).json();const pair=definitions.PromptManagerPair;
|
||||
expect(pair).toBeDefined();expect(pair.output).toEqual(['CONDITIONING','CONDITIONING','STRING','STRING']);
|
||||
expect(pair.input.required.text[0]).toBe('STRING');expect(pair.input.required.negative_text[0]).toBe('STRING');expect(pair.input.required.clip[0]).toBe('CLIP');
|
||||
for(const name of ['prepend_text','append_text'])expect(pair.input.optional[name]).toMatchObject(['STRING',{default:'',forceInput:true}]);
|
||||
expect(pair.input.optional.negative_prepend_text).toBeUndefined();
|
||||
});
|
||||
|
||||
test('paired library previews both, atomically loads, queues and survives renderer switches and reload',async({page,request},testInfo)=>{
|
||||
const positive=`Pair positive ${testInfo.project.name}`;const negative=`Pair negative ${testInfo.project.name}`;await seed(request,positive,negative);const id=await addPair(page);const root=page.locator('.pm-pair');
|
||||
const pos=root.getByRole('textbox',{name:'Positive prompt',exact:true});const neg=root.getByRole('textbox',{name:'Negative prompt',exact:true});
|
||||
await pos.fill('Local positive');await neg.fill('Local negative');
|
||||
for(const [name,field]of [['text',pos],['negative_text',neg]])expect(await field.evaluate(async(element,{id,name})=>{const {app}=await import('/scripts/app.js');return app.graph.getNodeById(id).widgets.find(w=>w.name===name).inputEl===element;},{id,name})).toBe(true);
|
||||
await select(page,positive);await expect(root.locator('.pm-editor-selection')).toContainText(negative);await expect(pos).toHaveValue('Local positive');await expect(neg).toHaveValue('Local negative');
|
||||
await root.getByRole('button',{name:'Use pair',exact:true}).click();await expect(pos).toHaveValue(positive);await expect(neg).toHaveValue(negative);
|
||||
await root.getByText('Composition · prepend / append',{exact:true}).click();await root.getByRole('textbox',{name:'Prepend',exact:true}).fill('trigger');await root.getByRole('textbox',{name:'Append',exact:true}).fill('detail');
|
||||
for(const renderer of [!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]){
|
||||
await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);await expect(pos).toHaveValue(positive);await expect(neg).toHaveValue(negative);
|
||||
}
|
||||
const queued=await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const inputs=(await app.graphToPrompt()).output[String(id)].inputs;await app.loadGraphData(app.graph.serialize());return inputs;},id);
|
||||
expect(queued).toMatchObject({text:positive,negative_text:negative,prepend_text:'trigger',append_text:'detail'});await expect(pos).toHaveValue(positive);await expect(neg).toHaveValue(negative);
|
||||
await page.screenshot({path:testInfo.outputPath('pair-reloaded.png')});
|
||||
await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');app.graph.remove(app.graph.getNodeById(id));},id);await expect(root).toHaveCount(0);await expect(page.locator('.ca-completions')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('paired real STRING connections stay linked and restore both local editors after disconnect',async({page,request},testInfo)=>{
|
||||
const positive=`Connected pair ${testInfo.project.name}`;await seed(request,positive,'Connected library negative');const id=await addPair(page);const root=page.locator('.pm-pair');
|
||||
await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Local positive');await root.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Local negative');
|
||||
await root.getByText('Composition · prepend / append',{exact:true}).click();await root.getByRole('textbox',{name:'Prepend',exact:true}).fill('Local prepend');await root.getByRole('textbox',{name:'Append',exact:true}).fill('Local append');
|
||||
const links=await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');const node=app.graph.getNodeById(id);for(const name of ['text','negative_text'])convertToInput(node,node.widgets.find(w=>w.name===name));const result={};for(const name of ['text','negative_text','prepend_text','append_text']){const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);source.widgets.find(w=>w.name==='value').value=`Connected ${name}`;source.connect(0,node,node.inputs.findIndex(i=>i.name===name));result[name]=[String(source.id),0];}app.canvas.centerOnNode(node);return result;},id);
|
||||
await select(page,positive);await expect(root.getByRole('button',{name:'Use pair',exact:true})).toBeDisabled();await expect(root.getByRole('button',{name:'Save pair',exact:true})).toBeDisabled();
|
||||
for(const name of ['Positive prompt','Negative prompt','Prepend','Append'])await expect(root.getByRole('textbox',{name,exact:true})).toBeHidden();
|
||||
const before=await values(page,id);await root.locator('textarea,input').evaluateAll(inputs=>{for(const input of inputs){if(!input.hidden&&!input.closest('[hidden]'))continue;input.value='Overwrite attempt';input.dispatchEvent(new Event('input',{bubbles:true}));}});expect(await values(page,id)).toMatchObject(before);
|
||||
for(const reload of [false,true]){const queued=await page.evaluate(async({id,reload})=>{const {app}=await import('/scripts/app.js');if(reload)await app.loadGraphData(app.graph.serialize());return(await app.graphToPrompt()).output[String(id)].inputs;},{id,reload});expect(queued).toMatchObject(links);}
|
||||
const local=await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);for(const name of ['text','negative_text','prepend_text','append_text'])node.disconnectInput(node.inputs.findIndex(i=>i.name===name));return(await app.graphToPrompt()).output[String(id)].inputs;},id);
|
||||
expect(local).toMatchObject({text:'Local positive',negative_text:'Local negative',prepend_text:'Local prepend',append_text:'Local append'});
|
||||
for(const [name,value]of [['Positive prompt','Local positive'],['Negative prompt','Local negative'],['Prepend','Local prepend'],['Append','Local append']]){const field=root.getByRole('textbox',{name,exact:true});await expect(field).toHaveCount(1);await expect(field).toBeEditable();await expect(field).toHaveValue(value);}
|
||||
});
|
||||
|
||||
test('paired default layout uses equal editors and follows real host themes',async({page},testInfo)=>{
|
||||
await addPair(page);const root=page.locator('.pm-pair');await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Positive draft');await root.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Negative draft');
|
||||
for(const palette of ['light','dark']){await page.evaluate(async palette=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.ColorPalette',palette);},palette);await expect(root).toHaveAttribute('data-ca-theme',palette);
|
||||
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>root.getByRole('textbox',{name,exact:true}).boundingBox()));expect(Math.abs(p.width-n.width)).toBeLessThan(1);expect(Math.abs(p.height-n.height)).toBeLessThan(1);expect(p.height).toBeGreaterThan(100);
|
||||
await page.screenshot({path:testInfo.outputPath(`pair-${palette}.png`)});
|
||||
}
|
||||
});
|
||||
|
||||
test('paired explicit Save persists an empty negative through actual API and preserves local draft',async({page,request},testInfo)=>{
|
||||
const id=await addPair(page);const root=page.locator('.pm-pair');const text=`Saved pair empty negative ${testInfo.project.name}`;await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(text);await root.getByRole('button',{name:'Save pair',exact:true}).click();
|
||||
await expect(root.getByRole('status').filter({hasText:'Pair saved.'})).toBeVisible();const response=await request.get(`/prompt_manager/pairs?text=${encodeURIComponent(text)}`);expect(response.ok()).toBe(true);expect((await response.json()).pairs).toEqual(expect.arrayContaining([expect.objectContaining({positive_text:text,negative_text:''})]));expect(await values(page,id)).toMatchObject({text,negative_text:''});
|
||||
});
|
||||
|
||||
test('paired library filters search both texts, persist on reload and keep editor metadata separate',async({page,request},testInfo)=>{
|
||||
const token=`paired-filter-${testInfo.project.name}`;await seed(request,`Matching ${token}`,`Negative ${token}`);const other=await request.post('/prompt_manager/pairs',{data:{positive_text:`Other ${token}`,negative_text:'different',category:'Other paired category',tags:['other-pair-tag']}});expect(other.ok()).toBe(true);
|
||||
const id=await addPair(page);const root=page.locator('.pm-pair');await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Draft positive');await root.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Draft negative');
|
||||
await root.getByText('Metadata · category / tags',{exact:true}).click();await root.getByRole('combobox',{name:'Category',exact:true}).fill('Keep category');await root.getByRole('combobox',{name:'Tags',exact:true}).fill('keep-tag');await root.getByText('Metadata · category / tags',{exact:true}).click();
|
||||
await root.getByRole('searchbox',{name:'Search saved prompts'}).fill(token);await root.getByLabel('Filter category',{exact:true}).fill('Paired host tests');await root.getByLabel('Filter tags',{exact:true}).fill('pair-host, ');await root.getByRole('button',{name:'Search',exact:true}).click();
|
||||
await expect(root.locator('.pm-editor-result')).toHaveCount(1);await root.locator('.pm-editor-result').click();await expect(root.locator('.pm-editor-selection')).toContainText(`Negative ${token}`);
|
||||
expect(await values(page,id)).toMatchObject({text:'Draft positive',negative_text:'Draft negative',category:'Keep category',tags:'keep-tag'});await root.getByRole('switch',{name:'Load metadata'}).uncheck();await root.getByRole('button',{name:'Use pair',exact:true}).click();expect(await values(page,id)).toMatchObject({text:`Matching ${token}`,negative_text:`Negative ${token}`,category:'Keep category',tags:'keep-tag'});
|
||||
await page.screenshot({path:testInfo.outputPath('pair-library-preview.png')});
|
||||
await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');await app.loadGraphData(app.graph.serialize());});await expect(root.getByLabel('Filter category',{exact:true})).toHaveValue('Paired host tests');await expect(root.getByLabel('Filter tags',{exact:true})).toHaveValue('pair-host, ');
|
||||
await page.evaluate(async id=>{const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);node.setSize([560,1000]);app.canvas.centerOnNode(node);},id);
|
||||
const [p,n]=await Promise.all(['Positive prompt','Negative prompt'].map(name=>root.getByRole('textbox',{name,exact:true}).boundingBox()));expect(n.y).toBeGreaterThan(p.y+p.height-1);expect(Math.abs(p.width-n.width)).toBeLessThan(1);await page.screenshot({path:testInfo.outputPath('pair-narrow.png')});
|
||||
});
|
||||
|
||||
for(const field of ['category','tags'])test(`manual Save pair guards real connected ${field} and resumes after disconnect`,async({page},testInfo)=>{
|
||||
const id=await addPair(page);const root=page.locator('.pm-pair');await root.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(`Metadata link ${field} ${testInfo.project.name}`);
|
||||
let saves=0;await page.route('**/prompt_manager/pairs',async route=>{if(route.request().method()==='POST')saves++;await route.continue();});
|
||||
const link=await page.evaluate(async({id,field})=>{const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');const target=app.graph.getNodeById(id);convertToInput(target,target.widgets.find(widget=>widget.name===field));const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);source.widgets.find(widget=>widget.name==='value').value='Connected metadata';source.connect(0,target,target.inputs.findIndex(input=>input.name===field));app.canvas.centerOnNode(target);return [String(source.id),0];},{id,field});
|
||||
const save=root.getByRole('button',{name:'Save pair',exact:true});await expect(save).toBeDisabled();await expect(root.getByRole('status').filter({hasText:'Run the node to save connected values.'})).toBeVisible();await save.evaluate(button=>button.dispatchEvent(new Event('click',{bubbles:true})));expect(saves).toBe(0);
|
||||
const input=await page.evaluate(async({id,field})=>{const {app}=await import('/scripts/app.js');return(await app.graphToPrompt()).output[String(id)].inputs[field];},{id,field});expect(input).toEqual(link);
|
||||
await page.evaluate(async({id,field})=>{const {app}=await import('/scripts/app.js');const target=app.graph.getNodeById(id);target.disconnectInput(target.inputs.findIndex(input=>input.name===field));},{id,field});await expect(save).toBeEnabled();await save.click();await expect(root.getByRole('status').filter({hasText:'Pair saved.'})).toBeVisible();expect(saves).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { installHostHooks } from './host-fixture.mjs';
|
||||
|
||||
installHostHooks(test);
|
||||
|
||||
test('workspace serves real library data beside either ComfyUI renderer', async ({ page, context, request }, testInfo) => {
|
||||
const saved = await request.post('/prompt_manager/save', { data: {
|
||||
text: `Workspace host smoke ${testInfo.project.name}`, tags: ['workspace-host'],
|
||||
} });
|
||||
expect(saved.ok()).toBe(true);
|
||||
const stats = (await (await request.get('/prompt_manager/stats')).json()).stats;
|
||||
const workspace = await context.newPage();
|
||||
const errors = [];
|
||||
workspace.on('pageerror', error => errors.push(error.message));
|
||||
try {
|
||||
await workspace.goto('/prompt_manager/workspace');
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
|
||||
.toHaveText(stats.total_prompts.toLocaleString());
|
||||
await workspace.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
|
||||
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
await workspace.getByRole('navigation', { name: 'Workspace', exact: true })
|
||||
.getByRole('link', { name: 'Images', exact: true }).click();
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(workspace.locator('#workspace-main a')).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
await workspace.reload();
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
await workspace.screenshot({ path: testInfo.outputPath('workspace-host.png') });
|
||||
expect(errors).toEqual([]);
|
||||
expect(await page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
|
||||
} finally {
|
||||
await workspace.close();
|
||||
}
|
||||
});
|
||||
@@ -361,3 +361,36 @@ test("Focused workflow-only metadata recovers serialized fields and connected co
|
||||
assert.equal(result.negativePrompt, "avoid blurry watermark");
|
||||
assert.equal(result.checkpoint, "model.safetensors");
|
||||
});
|
||||
|
||||
test("Split API metadata composes linked positive text and preserves negative role", () => {
|
||||
const prompt = {
|
||||
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
|
||||
2: { class_type: "PromptManagerSplit", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
|
||||
3: { class_type: "PromptManagerSplit", inputs: { text: "blurry", prepend_text: "avoid" } },
|
||||
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
|
||||
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
|
||||
};
|
||||
const result = extractGenerationParams({ prompt });
|
||||
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
|
||||
assert.equal(result.negativePrompt, "avoid blurry");
|
||||
});
|
||||
|
||||
test("Split workflow-only metadata recovers serialized fields and connected composition", () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
|
||||
{ id: 2, type: "PromptManagerSplit", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
|
||||
{ id: 3, type: "PromptManagerSplit", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
|
||||
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
|
||||
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
|
||||
],
|
||||
links: [
|
||||
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
|
||||
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
|
||||
],
|
||||
};
|
||||
const result = extractGenerationParams({ workflow });
|
||||
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
|
||||
assert.equal(result.negativePrompt, "avoid blurry watermark");
|
||||
assert.equal(result.checkpoint, "model.safetensors");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const ImageHelpers = require('../../web/js/image-helpers.js');
|
||||
const {extractGenerationParams} = require('../../web/js/comfy-metadata.js');
|
||||
|
||||
function graph(positive = 0, negative = 1) {
|
||||
return {
|
||||
1:{class_type:'PromptManagerPair',inputs:{text:'cat',negative_text:'blur',prepend_text:'trigger',append_text:'detail'}},
|
||||
2:{class_type:'KSampler',inputs:{positive:['1',positive],negative:['1',negative],model:['3',0]}},
|
||||
3:{class_type:'CheckpointLoaderSimple',inputs:{ckpt_name:'m.safetensors'}},
|
||||
};
|
||||
}
|
||||
|
||||
test('paired conditioning uses actual output roles and composes only positive', () => {
|
||||
const metadata = extractGenerationParams({prompt:graph()});
|
||||
assert.equal(metadata.positivePrompt,'trigger cat detail');
|
||||
assert.equal(metadata.negativePrompt,'blur');
|
||||
const swapped = extractGenerationParams({prompt:graph(1,0)});
|
||||
assert.equal(swapped.positivePrompt,'blur');
|
||||
assert.equal(swapped.negativePrompt,'trigger cat detail');
|
||||
});
|
||||
|
||||
test('paired strings passed through encoders retain the connected output slot', () => {
|
||||
const prompt=graph();
|
||||
prompt[4]={class_type:'CLIPTextEncode',inputs:{text:['1',2]}};
|
||||
prompt[5]={class_type:'CLIPTextEncode',inputs:{text:['1',3]}};
|
||||
prompt[2].inputs.positive=['4',0];prompt[2].inputs.negative=['5',0];
|
||||
const metadata=extractGenerationParams({prompt});
|
||||
assert.equal(metadata.positivePrompt,'trigger cat detail');
|
||||
assert.equal(metadata.negativePrompt,'blur');
|
||||
});
|
||||
|
||||
test('recorded image pair overrides unrelated PNG fallback, including an empty negative', async () => {
|
||||
for (const negative of ['blur','']) {
|
||||
const context=vm.createContext({document:{addEventListener(){}},window:{},console,ImageHelpers,
|
||||
ApiClient:{fetchUrl:async()=>({ok:true,json:async()=>({success:true,prompt:{text:'raw cat',pair:{negative_text:negative},pair_context:{positive_text:'trigger cat',negative_text:negative}}})})},
|
||||
});
|
||||
const Manager=vm.runInContext(fs.readFileSync(path.join(__dirname,'../../web/js/gallery.js'),'utf8')+'\nGalleryManager;',context);
|
||||
const manager=Object.create(Manager.prototype);
|
||||
manager.extractImageMetadata=async()=>({positivePrompt:'wrong',negativePrompt:'wrong'});
|
||||
manager.displayMetadata=data=>{manager.last=data;};
|
||||
await manager.loadImageMetadata('/prompt_manager/images/serve/example.png',{});
|
||||
assert.equal(manager.last.positivePrompt,'trigger cat');
|
||||
assert.equal(manager.last.negativePrompt,negative);
|
||||
}
|
||||
});
|
||||
|
||||
test('gallery labels graph-inferred text separately from recorded execution text', () => {
|
||||
const context=vm.createContext({document:{addEventListener(){}},window:{},console,escapeHtml:value=>String(value)});
|
||||
const Manager=vm.runInContext(fs.readFileSync(path.join(__dirname,'../../web/js/gallery.js'),'utf8')+'\nGalleryManager;',context);
|
||||
const manager=Object.create(Manager.prototype);
|
||||
manager.formatPromptText=value=>String(value||'');manager.setupMetadataActions=()=>{};
|
||||
for (const [source,label] of [['graph-inferred','Recovered from workflow metadata'],['execution','Recorded during execution'],['embedded-parameters','Read from embedded parameters'],['mixed','Execution and workflow metadata']]) {
|
||||
const panel={};
|
||||
manager.displayMetadata({positivePrompt:'cat',negativePrompt:'blur',textSource:source},panel);
|
||||
assert.ok(panel.innerHTML.includes(label),source);
|
||||
}
|
||||
});
|
||||
@@ -76,3 +76,30 @@ test("progressLabel summarises a running scan for the minimized pill", () => {
|
||||
assert.equal(progressLabel(running), "Scanning 42% · 84 files · 12 prompts");
|
||||
assert.equal(progressLabel(initialState()), "");
|
||||
});
|
||||
|
||||
test('pair totals survive completion and cancellation closes the running state', () => {
|
||||
const running=reduceScanEvent(initialState(),{type:'progress',progress:30,processed:3});
|
||||
const done=reduceScanEvent(running,{type:'complete',pairs_added:2,pair_links_added:3,enriched:1,unchanged:4,ambiguous:1,failed:2});
|
||||
assert.deepEqual([done.pairs_added,done.pair_links_added,done.enriched,done.unchanged,done.ambiguous,done.failed],[2,3,1,4,1,2]);
|
||||
const cancelled=reduceScanEvent(running,{type:'cancelled',processed:3,pairs_added:1});
|
||||
assert.equal(cancelled.status,'done');
|
||||
assert.equal(cancelled.cancelled,true);
|
||||
assert.equal(cancelled.statusText,'Scan cancelled.');
|
||||
assert.equal(cancelled.progress,30);
|
||||
});
|
||||
|
||||
test('completion label reports pair enrichment, unresolved work and failures', () => {
|
||||
const {completionLabel}=require('../../web/js/scan-progress.js');
|
||||
const state=reduceScanEvent(initialState(),{type:'complete',processed:10,found:8,added:1,linked:2,pairs_added:3,pair_links_added:4,enriched:2,unchanged:1,ambiguous:1,unresolved:2,conflict:1,failed:1});
|
||||
assert.match(completionLabel(state),/3 new pairs, 4 pair links/);
|
||||
assert.match(completionLabel(state),/4 need review, 1 failed/);
|
||||
assert.match(completionLabel({...state,cancelled:true}),/^Scan cancelled!/);
|
||||
});
|
||||
|
||||
test('older servers without pair counts retain a complete readable summary', () => {
|
||||
const {completionLabel}=require('../../web/js/scan-progress.js');
|
||||
const state=reduceScanEvent(initialState(),{type:'complete',processed:2,found:1,added:1,linked:0});
|
||||
assert.match(completionLabel(initialState()),/0 new pairs, 0 pair links; 0 need review, 0 failed/);
|
||||
assert.match(completionLabel(state),/0 enriched, 0 unchanged/);
|
||||
assert.equal(fromStatus({running:true,last_event:{type:'unknown'}}).status,'running');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/state.mjs');
|
||||
|
||||
test('workspace routes resolve only supported deep links', async () => {
|
||||
const { resolveRoute } = await source;
|
||||
for (const route of ['overview', 'images', 'library', 'tags', 'inspect', 'system']) {
|
||||
assert.equal(resolveRoute(`#/${route}`).id, route);
|
||||
}
|
||||
for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>']) {
|
||||
assert.equal(resolveRoute(hash).id, 'overview');
|
||||
}
|
||||
});
|
||||
|
||||
test('statistics preserve zero and reject incomplete or invalid counts', async () => {
|
||||
const { summarizeStats } = await source;
|
||||
assert.deepEqual(summarizeStats({total_prompts:0, total_images:12, total_tags:3}), [
|
||||
['Prompts', 0], ['Images', 12], ['Tags', 3],
|
||||
]);
|
||||
for (const stats of [undefined, null, {}, {total_prompts:0, total_images:-1, total_tags:0},
|
||||
{total_prompts:'2', total_images:0, total_tags:0},
|
||||
{total_prompts:Infinity, total_images:0, total_tags:0},
|
||||
{total_prompts:0, total_images:1.5, total_tags:0}]) {
|
||||
assert.throws(() => summarizeStats(stats), /statistics/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('router normalizes unknown routes, avoids duplicate renders, and disposes its listener', async () => {
|
||||
const { mountRouter } = await source;
|
||||
const win = new EventTarget();
|
||||
win.location = {hash:'#/missing'};
|
||||
const replacements = [];
|
||||
win.history = {state: {keep:1}, replaceState(state, unused, hash) {
|
||||
replacements.push([state, hash]); win.location.hash = hash;
|
||||
}};
|
||||
const seen = [];
|
||||
const dispose = mountRouter(win, route => seen.push(route.id));
|
||||
assert.deepEqual(seen, ['overview']);
|
||||
assert.deepEqual(replacements, [[{keep:1}, '#/overview']]);
|
||||
win.location.hash = '#/images';
|
||||
win.dispatchEvent(new Event('hashchange'));
|
||||
win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen, ['overview', 'images']);
|
||||
dispose();
|
||||
dispose();
|
||||
win.location.hash = '#/system';
|
||||
win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen, ['overview', 'images']);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createScope } from '../../web/js/shared/toolkit/core.js';
|
||||
import { mountWorkspaceTheme } from '../../web/js/workspace/theme.mjs';
|
||||
|
||||
function fixture({ dark = false, preference, fetchImpl, storageDenied = false, pathname = '/prompt_manager/workspace' } = {}) {
|
||||
const media = new EventTarget(); media.matches = dark;
|
||||
const win = new EventTarget(); win.location = { pathname }; win.matchMedia = () => media;
|
||||
const documentElement = { style: { colorScheme: 'light dark' } };
|
||||
const root = { dataset: {}, ownerDocument: { documentElement } };
|
||||
const saved = new Map(preference === undefined ? [] : [['pm_workspace_theme', preference]]);
|
||||
const storage = {
|
||||
getItem(key) { if (storageDenied) throw new Error('denied'); return saved.get(key); },
|
||||
setItem(key, value) { if (storageDenied) throw new Error('denied'); saved.set(key, value); },
|
||||
};
|
||||
const calls = [];
|
||||
const scope = createScope();
|
||||
const theme = mountWorkspaceTheme({ root, scope, win, storage, fetchImpl: fetchImpl || (async (url, init) => {
|
||||
calls.push({ url, init }); return { ok: true, json: async () => 'dark' };
|
||||
}) });
|
||||
return { root, documentElement, saved, media, win, scope, theme, calls };
|
||||
}
|
||||
const response = value => ({ ok: true, json: async () => value });
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
test('follow uses host palette over OS and preserves reverse proxy prefix', async () => {
|
||||
const f = fixture({ pathname: '/proxy/comfy/prompt_manager/workspace' });
|
||||
await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
assert.equal(f.documentElement.style.colorScheme, 'dark');
|
||||
assert.equal(f.calls[0].url, '/proxy/comfy/api/settings/Comfy.ColorPalette');
|
||||
f.scope.dispose();
|
||||
});
|
||||
|
||||
test('supported light palettes accept string and wrapped values', async () => {
|
||||
for (const palette of ['light', 'solarized', 'github', 'arc']) {
|
||||
const f = fixture({ dark: true, fetchImpl: async () => response({ value: palette }) });
|
||||
await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'light', palette);
|
||||
f.scope.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit overrides persist and follow restores host choice', async () => {
|
||||
const f = fixture({ preference: 'light' }); await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'light');
|
||||
f.theme.setPreference('dark'); assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
assert.equal(f.saved.get('pm_workspace_theme'), 'dark');
|
||||
f.theme.setPreference('follow'); assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
assert.equal(f.saved.get('pm_workspace_theme'), 'follow');
|
||||
assert.throws(() => f.theme.setPreference('orange'), /preference/i);
|
||||
f.scope.dispose();
|
||||
});
|
||||
|
||||
test('unknown host palettes and invalid stored preferences follow changing OS', async () => {
|
||||
const f = fixture({ preference: 'orange', fetchImpl: async () => response('custom') });
|
||||
await f.theme.ready; assert.equal(f.root.dataset.caTheme, 'light');
|
||||
f.media.matches = true; f.media.dispatchEvent(new Event('change'));
|
||||
assert.equal(f.root.dataset.caTheme, 'dark'); f.scope.dispose();
|
||||
});
|
||||
|
||||
test('network, HTTP, JSON and storage failures recover using system theme', async () => {
|
||||
for (const fetchImpl of [async () => { throw new Error('offline'); }, async () => ({ ok: false }), async () => ({ ok: true, json: async () => { throw new Error('invalid'); } })]) {
|
||||
const f = fixture({ dark: true, storageDenied: true, fetchImpl });
|
||||
await f.theme.ready; assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
assert.doesNotThrow(() => f.theme.setPreference('light'));
|
||||
assert.equal(f.root.dataset.caTheme, 'light'); f.scope.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('focus refreshes host palette and stale responses cannot replace newest palette', async () => {
|
||||
const pending = [];
|
||||
const f = fixture({ fetchImpl: (url, init) => new Promise(resolve => pending.push({ resolve, signal: init.signal })) });
|
||||
f.win.dispatchEvent(new Event('focus'));
|
||||
assert.equal(pending.length, 2); assert.equal(pending[0].signal.aborted, true);
|
||||
pending[1].resolve(response('light')); await tick();
|
||||
pending[0].resolve(response('dark')); await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'light'); f.scope.dispose();
|
||||
});
|
||||
|
||||
test('cleanup aborts requests, restores attributes and native scheme and removes listeners', async () => {
|
||||
let resolve, fSignal;
|
||||
const f = fixture({ fetchImpl: (url, init) => new Promise(done => { resolve = done; fSignal = init.signal; }) });
|
||||
f.scope.dispose(); assert.equal(fSignal.aborted, true);
|
||||
assert.equal(f.root.dataset.caTheme, undefined);
|
||||
assert.equal(f.documentElement.style.colorScheme, 'light dark');
|
||||
f.win.dispatchEvent(new Event('focus')); f.media.dispatchEvent(new Event('change'));
|
||||
resolve(response('dark')); await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, undefined);
|
||||
f.theme.setPreference('dark'); assert.equal(f.root.dataset.caTheme, undefined);
|
||||
});
|
||||
|
||||
test('cleanup restores an existing toolkit theme', async () => {
|
||||
const f = fixture(); await f.theme.ready; f.scope.dispose();
|
||||
f.root.dataset.caTheme = 'light';
|
||||
const scope = createScope();
|
||||
const theme = mountWorkspaceTheme({ root: f.root, scope, win: f.win, storage: f.saved, fetchImpl: async () => response('dark') });
|
||||
await theme.ready; scope.dispose(); assert.equal(f.root.dataset.caTheme, 'light');
|
||||
});
|
||||
|
||||
|
||||
test('malformed palette payloads fall back without losing native theme alignment', async () => {
|
||||
for (const payload of [null, {}, {value: 42}, 42]) {
|
||||
const f = fixture({ dark: true, fetchImpl: async () => response(payload) });
|
||||
await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
assert.equal(f.documentElement.style.colorScheme, 'dark');
|
||||
f.scope.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('focus failure clears obsolete host palette and resumes system following', async () => {
|
||||
let calls = 0;
|
||||
const f = fixture({ fetchImpl: async () => {
|
||||
if (++calls === 1) return response('dark');
|
||||
throw new Error('host offline');
|
||||
} });
|
||||
await f.theme.ready;
|
||||
assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
f.win.dispatchEvent(new Event('focus')); await tick();
|
||||
assert.equal(f.root.dataset.caTheme, 'light');
|
||||
f.scope.dispose();
|
||||
f.win.dispatchEvent(new Event('focus')); await tick();
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('blocked localStorage getter and missing proxy marker stay usable', async () => {
|
||||
const f = fixture(); await f.theme.ready; f.scope.dispose();
|
||||
Object.defineProperty(f.win, 'localStorage', { get() { throw new Error('denied'); } });
|
||||
f.win.location.pathname = '/standalone';
|
||||
const scope = createScope();
|
||||
let url;
|
||||
const theme = mountWorkspaceTheme({ root: f.root, win: f.win, scope,
|
||||
fetchImpl: async value => { url = value; return response(' Obsidian '); } });
|
||||
assert.equal(theme.preference, 'follow');
|
||||
await theme.ready;
|
||||
assert.equal(url, '/api/settings/Comfy.ColorPalette');
|
||||
assert.equal(f.root.dataset.caTheme, 'dark');
|
||||
theme.setPreference('light');
|
||||
assert.equal(theme.preference, 'light');
|
||||
scope.dispose();
|
||||
});
|
||||
@@ -31,8 +31,7 @@ class APITestCase(AioHTTPTestCase):
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self._temp_db.name)
|
||||
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
|
||||
@@ -70,8 +70,7 @@ class AdminAPITestCase(AioHTTPTestCase):
|
||||
db_path = os.path.join(self.tmpdir, "prompts.db")
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(db_path)
|
||||
self.api = PromptManagerAPI(db=PromptDatabase(db_path))
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
@@ -476,10 +475,13 @@ class TestScansRunOffTheEventLoop(AdminAPITestCase):
|
||||
).text()
|
||||
|
||||
self.assertIn('"added": 1', first)
|
||||
self.assertIn('"linked": 1', second)
|
||||
self.assertIn('"unchanged": 1', second)
|
||||
self.assertIn('"added": 0', second)
|
||||
prompts = self.api.db.get_recent_prompts(limit=10)["prompts"]
|
||||
self.assertEqual([p["text"] for p in prompts], ["a red square"])
|
||||
self.assertEqual({p["text"] for p in prompts}, {"a red square", "bad"})
|
||||
pairs = self.api.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(len(pairs), 1)
|
||||
self.assertEqual(pairs[0]["negative_text"], "bad")
|
||||
|
||||
async def test_output_scan_without_directories_reports_error(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.outside_dir)]
|
||||
@@ -1556,7 +1558,7 @@ class TestOutputScanInternals(AdminAPITestCase):
|
||||
"found",
|
||||
)
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "added")
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "linked")
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "unchanged")
|
||||
|
||||
def test_ingest_link_failures_are_tolerated(self):
|
||||
f = Path("gen.png")
|
||||
@@ -1589,7 +1591,23 @@ class TestOutputScanInternals(AdminAPITestCase):
|
||||
counts = self.api._ingest_scan_batch_sync(
|
||||
[(Path("a.png"), meta), (Path("b.png"), {})]
|
||||
)
|
||||
self.assertEqual(counts, {"processed": 2, "found": 0, "added": 0, "linked": 0})
|
||||
self.assertEqual(
|
||||
counts,
|
||||
{
|
||||
"processed": 2,
|
||||
"found": 0,
|
||||
"added": 0,
|
||||
"linked": 0,
|
||||
"enriched": 0,
|
||||
"unchanged": 0,
|
||||
"ambiguous": 0,
|
||||
"unresolved": 0,
|
||||
"conflict": 0,
|
||||
"failed": 1,
|
||||
"pairs_added": 0,
|
||||
"pair_links_added": 0,
|
||||
},
|
||||
)
|
||||
|
||||
async def test_scan_internal_error_streams_error_event(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
@@ -1637,7 +1655,10 @@ class TestOutputScanIsABackgroundJob(AdminAPITestCase):
|
||||
await asyncio.wait_for(self.api._scan_job.task, 10)
|
||||
|
||||
prompts = self.api.db.get_recent_prompts(limit=10)["prompts"]
|
||||
self.assertEqual([p["text"] for p in prompts], ["a red square"])
|
||||
self.assertEqual({p["text"] for p in prompts}, {"a red square", "bad"})
|
||||
pairs = self.api.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(len(pairs), 1)
|
||||
self.assertEqual(pairs[0]["negative_text"], "bad")
|
||||
self.assertEqual(self.api._scan_job.last_event["type"], "complete")
|
||||
self.assertFalse(self.api._scan_job.running)
|
||||
|
||||
@@ -1704,7 +1725,7 @@ class TestOutputScanIsABackgroundJob(AdminAPITestCase):
|
||||
body = await (await self.client.request("POST", "/prompt_manager/scan")).text()
|
||||
|
||||
self.assertEqual(len(calls), 2)
|
||||
self.assertIn('"linked": 1', body)
|
||||
self.assertIn('"unchanged": 1', body)
|
||||
|
||||
async def test_failed_scan_is_not_left_running(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
|
||||
@@ -119,8 +119,7 @@ class AutotagAPITestCase(AioHTTPTestCase):
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self._temp_db.name)
|
||||
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir]
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
|
||||
@@ -45,8 +45,7 @@ class BackupRestoreTestCase(AioHTTPTestCase):
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self.db_path)
|
||||
self.api = PromptManagerAPI(db=PromptDatabase(self.db_path))
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
|
||||
@@ -322,7 +322,9 @@ class TestLongJobLock(unittest.TestCase):
|
||||
"""_acquire_long_job: one job per family at a time, released on exit."""
|
||||
|
||||
def setUp(self):
|
||||
self.api = PromptManagerAPI()
|
||||
database = MagicMock()
|
||||
database.cleanup_duplicates.return_value = 0
|
||||
self.api = PromptManagerAPI(db=database)
|
||||
|
||||
def _run(self, coro):
|
||||
import asyncio
|
||||
@@ -373,8 +375,9 @@ class CoreRoutesTestCase(AioHTTPTestCase):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
self.api = PromptManagerAPI(
|
||||
db=PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
)
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
@@ -382,6 +385,7 @@ class CoreRoutesTestCase(AioHTTPTestCase):
|
||||
async def tearDownAsync(self):
|
||||
await super().tearDownAsync() # closes the aiohttp test client
|
||||
self.api.db.close_all()
|
||||
assert_closed(self, self.tmpdir)
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
def _fake_root(self):
|
||||
@@ -396,6 +400,7 @@ class CoreRoutesTestCase(AioHTTPTestCase):
|
||||
|
||||
|
||||
UI_PATHS = (
|
||||
"/prompt_manager/workspace",
|
||||
"/prompt_manager/web",
|
||||
"/prompt_manager/gallery.html",
|
||||
"/prompt_manager/admin",
|
||||
@@ -426,7 +431,13 @@ class TestUiRoutes(CoreRoutesTestCase):
|
||||
|
||||
async def test_unreadable_pages_are_500(self):
|
||||
web_dir = self._fake_root()
|
||||
for name in ("index.html", "metadata.html", "admin.html", "gallery.html"):
|
||||
for name in (
|
||||
"workspace.html",
|
||||
"index.html",
|
||||
"metadata.html",
|
||||
"admin.html",
|
||||
"gallery.html",
|
||||
):
|
||||
(web_dir / name).mkdir() # a directory cannot be read as a file
|
||||
for path in UI_PATHS:
|
||||
resp = await self.client.request("GET", path)
|
||||
@@ -445,6 +456,13 @@ class TestStaticRoutes(CoreRoutesTestCase):
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIn("application/javascript", resp.headers["Content-Type"])
|
||||
|
||||
async def test_workspace_module_served_with_javascript_mime(self):
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/js/workspace/index.mjs"
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIn("application/javascript", resp.headers["Content-Type"])
|
||||
|
||||
async def test_lib_served(self):
|
||||
web_dir = self._fake_root()
|
||||
(web_dir / "lib" / "x.css").write_text("body{}")
|
||||
@@ -565,8 +583,9 @@ class TestInstanceHelpers(FolderPathsFixture):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
self.api = PromptManagerAPI(
|
||||
db=PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
)
|
||||
self.addCleanup(self.api.db.close_all) # runs before the rmtree cleanup
|
||||
orig_dirs = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
self.addCleanup(setattr, GalleryConfig, "MONITORING_DIRECTORIES", orig_dirs)
|
||||
@@ -668,7 +687,9 @@ class TestInstanceHelpers(FolderPathsFixture):
|
||||
class TestMetadataParsing(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.api = PromptManagerAPI()
|
||||
database = MagicMock()
|
||||
database.cleanup_duplicates.return_value = 0
|
||||
self.api = PromptManagerAPI(db=database)
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.tmpdir, True)
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""API fixtures never open the configured database before installing test state."""
|
||||
|
||||
import unittest.mock
|
||||
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
|
||||
class TestAPIDatabaseIsolation(unittest.TestCase):
|
||||
def test_injected_database_never_constructs_the_configured_database(self):
|
||||
database = unittest.mock.MagicMock()
|
||||
database.cleanup_duplicates.return_value = 0
|
||||
with (
|
||||
unittest.mock.patch(
|
||||
"py.api.PromptDatabase", side_effect=AssertionError("default DB opened")
|
||||
) as factory,
|
||||
unittest.mock.patch("py.api.get_logger"),
|
||||
):
|
||||
api = PromptManagerAPI(db=database)
|
||||
self.assertIs(api.db, database)
|
||||
factory.assert_not_called()
|
||||
database.cleanup_duplicates.assert_called_once_with()
|
||||
|
||||
def test_falsey_injected_database_is_still_used(self):
|
||||
database = unittest.mock.MagicMock()
|
||||
database.__bool__.return_value = False
|
||||
database.cleanup_duplicates.return_value = 0
|
||||
with (
|
||||
unittest.mock.patch(
|
||||
"py.api.PromptDatabase", side_effect=AssertionError("default DB opened")
|
||||
) as factory,
|
||||
unittest.mock.patch("py.api.get_logger"),
|
||||
):
|
||||
api = PromptManagerAPI(db=database)
|
||||
self.assertIs(api.db, database)
|
||||
factory.assert_not_called()
|
||||
|
||||
def test_default_runtime_construction_still_uses_the_configured_factory(self):
|
||||
database = unittest.mock.MagicMock()
|
||||
database.cleanup_duplicates.return_value = 2
|
||||
with (
|
||||
unittest.mock.patch(
|
||||
"py.api.PromptDatabase", return_value=database
|
||||
) as factory,
|
||||
unittest.mock.patch("py.api.get_logger") as logger,
|
||||
):
|
||||
api = PromptManagerAPI()
|
||||
self.assertIs(api.db, database)
|
||||
factory.assert_called_once_with()
|
||||
logger.return_value.info.assert_any_call(
|
||||
"Startup cleanup: removed 2 duplicate prompts"
|
||||
)
|
||||
|
||||
def test_injected_cleanup_failure_is_logged_without_falling_back_to_default(self):
|
||||
database = unittest.mock.MagicMock()
|
||||
database.cleanup_duplicates.side_effect = RuntimeError("test cleanup")
|
||||
with (
|
||||
unittest.mock.patch(
|
||||
"py.api.PromptDatabase", side_effect=AssertionError("default DB opened")
|
||||
) as factory,
|
||||
unittest.mock.patch("py.api.get_logger") as logger,
|
||||
):
|
||||
api = PromptManagerAPI(db=database)
|
||||
self.assertIs(api.db, database)
|
||||
factory.assert_not_called()
|
||||
logger.return_value.error.assert_called_once_with(
|
||||
"Startup cleanup failed: test cleanup"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -64,8 +64,7 @@ class ImageAPITestCase(AioHTTPTestCase):
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self._temp_db.name)
|
||||
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir]
|
||||
self.api._find_comfyui_output_dir = lambda: str(self.output_dir)
|
||||
self.api.add_routes(routes)
|
||||
|
||||
@@ -104,8 +104,9 @@ class LoggingAPITestCase(AioHTTPTestCase):
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
self.api = PromptManagerAPI(
|
||||
db=PromptDatabase(os.path.join(self.tmpdir, "prompts.db"))
|
||||
)
|
||||
self.api._get_logger_manager = lambda: self.manager
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
|
||||
@@ -76,8 +76,7 @@ class LoraAPITestCase(AioHTTPTestCase):
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self._temp_db.name)
|
||||
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Pair routes preserve shared text and expose portable combinations."""
|
||||
|
||||
from tests.test_api_prompts import PromptAPITestCase
|
||||
|
||||
|
||||
class TestPairAPI(PromptAPITestCase):
|
||||
async def _create(self, **overrides):
|
||||
payload = {"positive_text": "cat", "negative_text": "blur"}
|
||||
payload.update(overrides)
|
||||
return await self.client.post("/prompt_manager/pairs", json=payload)
|
||||
|
||||
async def test_create_reuse_read_and_search(self):
|
||||
response = await self._create(tags=["studio"], title="Portrait")
|
||||
self.assertEqual(response.status, 200)
|
||||
pair = (await response.json())["pair"]
|
||||
response = await self._create(negative_text="noise")
|
||||
second = (await response.json())["pair"]
|
||||
self.assertEqual(pair["positive_prompt_id"], second["positive_prompt_id"])
|
||||
self.assertNotEqual(pair["id"], second["id"])
|
||||
response = await self.client.get("/prompt_manager/pairs?text=blur")
|
||||
data = await response.json()
|
||||
self.assertEqual(data["total"], 1)
|
||||
self.assertEqual(data["pairs"][0]["tags"], ["studio"])
|
||||
response = await self.client.get(f'/prompt_manager/pairs/{pair["id"]}')
|
||||
self.assertEqual((await response.json())["pair"]["title"], "Portrait")
|
||||
|
||||
async def test_negative_must_be_explicit_but_can_be_empty(self):
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/pairs", json={"positive_text": "cat"}
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
response = await self._create(negative_text="")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIsNone((await response.json())["pair"]["negative_prompt_id"])
|
||||
|
||||
async def test_invalid_payloads_are_400_without_writes(self):
|
||||
for body in (
|
||||
[],
|
||||
None,
|
||||
{},
|
||||
{"positive_text": 12, "negative_text": ""},
|
||||
{"positive_text": "cat", "negative_text": None},
|
||||
{"positive_text": "cat", "negative_text": "blur", "rating": True},
|
||||
{"positive_text": "cat", "negative_text": "blur", "tags": [12]},
|
||||
):
|
||||
response = await self.client.post("/prompt_manager/pairs", json=body)
|
||||
self.assertEqual(response.status, 400, body)
|
||||
response = await self.client.get("/prompt_manager/pairs")
|
||||
self.assertEqual((await response.json())["total"], 0)
|
||||
|
||||
async def test_repeated_save_does_not_overwrite_metadata(self):
|
||||
response = await self._create(title="Original", tags=["kept"])
|
||||
pair = (await response.json())["pair"]
|
||||
response = await self._create(title="Other", tags=["ignored"])
|
||||
repeated = (await response.json())["pair"]
|
||||
self.assertEqual(repeated["id"], pair["id"])
|
||||
self.assertEqual(repeated["title"], "Original")
|
||||
self.assertEqual(repeated["tags"], ["kept"])
|
||||
|
||||
async def test_metadata_update_and_archive(self):
|
||||
pair = (await (await self._create()).json())["pair"]
|
||||
url = f'/prompt_manager/pairs/{pair["id"]}'
|
||||
response = await self.client.patch(url, json={"title": "New", "tags": ["a"]})
|
||||
self.assertEqual(response.status, 200)
|
||||
response = await self.client.patch(url, json={"positive_text": "dog"})
|
||||
self.assertEqual(response.status, 400)
|
||||
response = await self.client.patch(url, json={"archived": True})
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(
|
||||
(await (await self.client.get("/prompt_manager/pairs")).json())["total"], 0
|
||||
)
|
||||
response = await self.client.get("/prompt_manager/pairs?include_archived=true")
|
||||
self.assertEqual((await response.json())["total"], 1)
|
||||
response = await self.client.delete(url)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIsNotNone(self.api.db.get_prompt_by_id(pair["positive_prompt_id"]))
|
||||
|
||||
async def test_export_is_versioned_and_contains_text_not_only_local_ids(self):
|
||||
await self._create(tags=["a"])
|
||||
response = await self.client.get("/prompt_manager/pairs/export")
|
||||
self.assertEqual(response.status, 200)
|
||||
data = await response.json()
|
||||
self.assertEqual(data["schema_version"], 1)
|
||||
self.assertEqual(data["pairs"][0]["positive_text"], "cat")
|
||||
self.assertEqual(data["pairs"][0]["negative_text"], "blur")
|
||||
self.assertNotIn("positive_prompt_id", data["pairs"][0])
|
||||
|
||||
async def test_missing_invalid_ids_and_pagination(self):
|
||||
for verb in ("get", "delete"):
|
||||
for suffix, status in (("abc", 400), ("9999", 404)):
|
||||
response = await getattr(self.client, verb)(
|
||||
f"/prompt_manager/pairs/{suffix}"
|
||||
)
|
||||
self.assertEqual(response.status, status)
|
||||
for query in ("limit=abc", "offset=abc", "include_archived=maybe"):
|
||||
response = await self.client.get("/prompt_manager/pairs?" + query)
|
||||
self.assertEqual(response.status, 400)
|
||||
response = await self.client.get("/prompt_manager/pairs?limit=99999&offset=-1")
|
||||
data = await response.json()
|
||||
self.assertEqual(data["limit"], 500)
|
||||
self.assertEqual(data["offset"], 0)
|
||||
|
||||
async def test_old_edit_and_delete_routes_report_pair_dependency(self):
|
||||
pair = (await (await self._create()).json())["pair"]
|
||||
prompt_id = pair["positive_prompt_id"]
|
||||
response = await self.client.put(
|
||||
f"/prompt_manager/prompts/{prompt_id}", json={"text": "dog"}
|
||||
)
|
||||
self.assertEqual(response.status, 409)
|
||||
response = await self.client.delete(f"/prompt_manager/delete/{prompt_id}")
|
||||
self.assertEqual(response.status, 409)
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/bulk/delete", json={"prompt_ids": [prompt_id]}
|
||||
)
|
||||
self.assertEqual(response.status, 409)
|
||||
|
||||
async def test_recent_pairs_sort_by_use_and_reject_unknown_sort(self):
|
||||
first = (await (await self._create()).json())["pair"]
|
||||
await self._create(negative_text="noise")
|
||||
self.api.db.record_prompt_pair_use(first["id"])
|
||||
response = await self.client.get("/prompt_manager/pairs?sort=last_used_desc")
|
||||
self.assertEqual((await response.json())["pairs"][0]["id"], first["id"])
|
||||
response = await self.client.get("/prompt_manager/pairs?sort=invalid")
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
async def test_null_metadata_patch_is_noop_for_existing_pair(self):
|
||||
pair = (await (await self._create(rating=4)).json())["pair"]
|
||||
response = await self.client.patch(
|
||||
f'/prompt_manager/pairs/{pair["id"]}', json={"rating": None}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual((await response.json())["pair"]["rating"], 4)
|
||||
response = await self.client.patch(
|
||||
"/prompt_manager/pairs/99999", json={"rating": None}
|
||||
)
|
||||
self.assertEqual(response.status, 404)
|
||||
|
||||
async def test_database_failures_are_generic_and_bad_json_is_rejected(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch.object(
|
||||
self.api.db,
|
||||
"save_prompt_pair",
|
||||
side_effect=RuntimeError("/private/db/path"),
|
||||
):
|
||||
response = await self._create()
|
||||
self.assertEqual(response.status, 500)
|
||||
self.assertNotIn("/private", await response.text())
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/pairs",
|
||||
data="{",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
@@ -17,6 +17,11 @@ sys.modules.setdefault("server", MagicMock())
|
||||
from aiohttp import web # noqa: E402
|
||||
from aiohttp.test_utils import AioHTTPTestCase # noqa: E402
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase # noqa: E402
|
||||
from py.api import PromptManagerAPI # noqa: E402
|
||||
import py.api.prompts as prompts_module # noqa: E402
|
||||
@@ -38,8 +43,7 @@ class PromptAPITestCase(AioHTTPTestCase):
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
self.api = PromptManagerAPI()
|
||||
self.api.db = PromptDatabase(self._temp_db.name)
|
||||
self.api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
|
||||
self.api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
return app
|
||||
@@ -48,6 +52,7 @@ class PromptAPITestCase(AioHTTPTestCase):
|
||||
await super().tearDownAsync() # closes the aiohttp test client
|
||||
# Windows refuses to unlink a database that still has open handles.
|
||||
self.api.db.close_all()
|
||||
assert_closed(self, self._temp_db.name)
|
||||
for path in (
|
||||
self._temp_db.name,
|
||||
self._temp_db.name + "-wal",
|
||||
|
||||
@@ -299,7 +299,9 @@ class TestGetAllOutputDirsFiltersRoots(GalleryRootTestCase):
|
||||
super().setUp()
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
self.api = PromptManagerAPI()
|
||||
database = MagicMock()
|
||||
database.cleanup_duplicates.return_value = 0
|
||||
self.api = PromptManagerAPI(db=database)
|
||||
self.api._cached_output_dir = None
|
||||
self._orig_dirs = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
self.addCleanup(self._restore_dirs)
|
||||
|
||||
@@ -138,11 +138,23 @@ class TestPackageWiring(PackageTestCase):
|
||||
[
|
||||
"PromptManager",
|
||||
"PromptManagerFocused",
|
||||
"PromptManagerPair",
|
||||
"PromptManagerSplit",
|
||||
"PromptManagerText",
|
||||
"PromptSearchList",
|
||||
],
|
||||
)
|
||||
|
||||
def test_split_display_name_and_execution_contract_are_registered(self):
|
||||
node = self.package.NODE_CLASS_MAPPINGS["PromptManagerSplit"]
|
||||
self.assertEqual(
|
||||
self.package.NODE_DISPLAY_NAME_MAPPINGS["PromptManagerSplit"],
|
||||
"Prompt Manager (Split)",
|
||||
)
|
||||
focused = self.package.NODE_CLASS_MAPPINGS["PromptManagerFocused"]
|
||||
self.assertTrue(issubclass(node, focused))
|
||||
self.assertEqual(node.INPUT_TYPES(), focused.INPUT_TYPES())
|
||||
|
||||
def test_db_factory_returns_the_shared_database(self):
|
||||
self.assertIsNotNone(self.package._global_db)
|
||||
self.assertIs(self.package._db_factory(), self.package._global_db)
|
||||
|
||||
@@ -9,7 +9,7 @@ import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
@@ -26,7 +26,16 @@ class TestFindComfyuiOutputDir(unittest.TestCase):
|
||||
"""Test _find_comfyui_output_dir() respects user-configured directories."""
|
||||
|
||||
def setUp(self):
|
||||
self.api = PromptManagerAPI()
|
||||
roots = patch.object(
|
||||
GalleryConfig,
|
||||
"allowed_gallery_parents",
|
||||
return_value=[os.path.normcase(os.path.realpath(tempfile.gettempdir()))],
|
||||
)
|
||||
roots.start()
|
||||
self.addCleanup(roots.stop)
|
||||
database = MagicMock()
|
||||
database.cleanup_duplicates.return_value = 0
|
||||
self.api = PromptManagerAPI(db=database)
|
||||
self.api._cached_output_dir = None
|
||||
self._orig_dirs = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
|
||||
@@ -112,5 +121,22 @@ class TestFindComfyuiOutputDir(unittest.TestCase):
|
||||
self.assertEqual(result, str(Path(tmpdir).resolve()))
|
||||
|
||||
|
||||
class TestOutputDirectoryFixturePortability(unittest.TestCase):
|
||||
def test_aliased_temporary_parent_uses_the_real_gallery_allowlist(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent = Path(directory)
|
||||
(parent / "alias").mkdir()
|
||||
(parent / "actual").mkdir()
|
||||
aliased = str(parent / "alias" / ".." / "actual")
|
||||
with patch("tempfile.gettempdir", return_value=aliased):
|
||||
case = TestFindComfyuiOutputDir(
|
||||
"test_configured_directory_takes_priority"
|
||||
)
|
||||
result = unittest.TestResult()
|
||||
case.run(result)
|
||||
self.assertEqual(result.errors, [])
|
||||
self.assertEqual(result.failures, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Dependency checks exclude competing pair writers until mutation finishes."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest.mock
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class TestPairDependencyConcurrency(unittest.TestCase):
|
||||
def test_legacy_mutators_lock_out_pair_creation_before_dependency_checks(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = os.path.join(directory, "concurrent.db")
|
||||
db = PromptDatabase(path)
|
||||
try:
|
||||
original = db._ensure_prompts_unpaired
|
||||
checks = []
|
||||
|
||||
def check_with_competing_writer(conn, ids):
|
||||
original(conn, ids)
|
||||
competitor = sqlite3.connect(path, timeout=0)
|
||||
try:
|
||||
with self.assertRaisesRegex(sqlite3.OperationalError, "locked"):
|
||||
competitor.execute("BEGIN IMMEDIATE")
|
||||
finally:
|
||||
competitor.close()
|
||||
checks.append(list(ids))
|
||||
|
||||
actions = (
|
||||
lambda pid: db.update_prompt_text(pid, "changed"),
|
||||
db.delete_prompt,
|
||||
lambda pid: db.bulk_delete_prompts([pid]),
|
||||
lambda _pid: db.delete_prompts_by_category("temporary"),
|
||||
)
|
||||
with unittest.mock.patch.object(
|
||||
db, "_ensure_prompts_unpaired", check_with_competing_writer
|
||||
):
|
||||
for index, action in enumerate(actions):
|
||||
with self.subTest(action=index):
|
||||
text = f"unpaired {index}"
|
||||
pid = db.save_prompt(
|
||||
text,
|
||||
category="temporary",
|
||||
prompt_hash=generate_prompt_hash(text),
|
||||
)
|
||||
self.assertTrue(action(pid))
|
||||
self.assertEqual(len(checks), len(actions))
|
||||
finally:
|
||||
db.close_all()
|
||||
assert_closed(self, directory)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Embedded actual encoding evidence is used only for its traced paired node."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from prompt_manager_pair import PromptManagerPair
|
||||
from utils.metadata_extractor import ComfyUIMetadataExtractor
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
KEY = "comfyassets_prompt_pairs"
|
||||
|
||||
|
||||
def graph(positive="cat", negative="blur"):
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": positive, "negative_text": negative},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["1", 1]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestPairExecution(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.tmp.name)
|
||||
self.db = PromptDatabase(str(Path(self.tmp.name) / "test.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
self.node = PromptManagerPair.__new__(PromptManagerPair)
|
||||
self.node.db = self.db
|
||||
self.node.logger = logging.getLogger("test.execution")
|
||||
self.node.prompt_tracker = Mock()
|
||||
self.node.comfyui_integration = Mock()
|
||||
self.node._inject_lora_trigger_words = lambda text: text + " LORA"
|
||||
self.extra = {"workflow": {"unchanged": True}, "sibling": {"keep": True}}
|
||||
|
||||
def execute(self, text="cat", negative="blur", **kwargs):
|
||||
return self.node.encode_prompt(
|
||||
Mock(),
|
||||
text,
|
||||
negative_text=negative,
|
||||
unique_id="1",
|
||||
extra_pnginfo=self.extra,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def resolve(self, source=None, extra=None):
|
||||
return resolve_prompt_pair(
|
||||
{"prompt": source or graph(), **(self.extra if extra is None else extra)}
|
||||
)
|
||||
|
||||
def test_schema_requests_extra_pnginfo_and_actual_positive_lora_is_preserved(self):
|
||||
self.assertEqual(
|
||||
PromptManagerPair.INPUT_TYPES()["hidden"]["extra_pnginfo"], "EXTRA_PNGINFO"
|
||||
)
|
||||
self.execute(prepend_text="trigger")
|
||||
source = graph()
|
||||
source["1"]["inputs"]["prepend_text"] = "trigger"
|
||||
result = self.resolve(source)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["positive"]["text"], "trigger cat LORA")
|
||||
self.assertEqual(result["positive"]["storage_text"], "cat")
|
||||
self.assertEqual(result["positive"]["provenance"]["text_source"], "execution")
|
||||
self.assertEqual(result["negative"]["text"], "blur")
|
||||
self.assertEqual(self.extra["workflow"], {"unchanged": True})
|
||||
self.assertEqual(self.extra["sibling"], {"keep": True})
|
||||
|
||||
def test_one_unknown_linked_execution_is_recoverable_without_fifo_guess(self):
|
||||
source = graph(positive=["2", 0])
|
||||
source["2"] = {"class_type": "PromptSearchList", "inputs": {}}
|
||||
self.execute(text="actual batch cat")
|
||||
result = self.resolve(source)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["positive"]["storage_text"], "actual batch cat")
|
||||
self.assertEqual(result["positive"]["text"], "actual batch cat LORA")
|
||||
|
||||
def test_distinct_batch_records_are_unresolved_instead_of_using_last_execution(
|
||||
self,
|
||||
):
|
||||
source = graph(positive=["2", 0])
|
||||
source["2"] = {"class_type": "PromptSearchList", "inputs": {}}
|
||||
self.execute(text="first")
|
||||
self.execute(text="second")
|
||||
result = self.resolve(source)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertIn("multiple", result["reason"].lower())
|
||||
|
||||
def test_empty_negative_runtime_record_remains_explicit(self):
|
||||
self.execute(negative="")
|
||||
result = self.resolve(graph(negative=""))
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["negative"]["text"], "")
|
||||
|
||||
def test_cached_graph_without_execution_evidence_is_explicitly_inferred(self):
|
||||
result = self.resolve(extra={})
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(
|
||||
result["positive"]["provenance"]["text_source"], "graph-inferred"
|
||||
)
|
||||
|
||||
def test_stale_or_malformed_evidence_is_not_accepted(self):
|
||||
self.execute(text="stale")
|
||||
self.assertEqual(self.resolve()["state"], "unresolved")
|
||||
for value in (
|
||||
"not json",
|
||||
[],
|
||||
{"version": 900, "nodes": {}},
|
||||
{"version": 1, "nodes": {"1": {"records": [{"bad": True}]}}},
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(
|
||||
self.resolve(extra={KEY: value})["state"], "unresolved"
|
||||
)
|
||||
|
||||
def test_unrelated_node_evidence_does_not_override_plain_encoders(self):
|
||||
source = graph()
|
||||
source["1"] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "plain positive"},
|
||||
}
|
||||
source["2"] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "plain negative"},
|
||||
}
|
||||
source["5"]["inputs"]["negative"] = ["2", 0]
|
||||
result = self.resolve(source, extra={KEY: "broken"})
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["positive"]["text"], "plain positive")
|
||||
|
||||
def test_png_extractor_preserves_namespace_for_live_and_raw_scan_readers(self):
|
||||
self.execute()
|
||||
path = Path(self.tmp.name) / "image.png"
|
||||
info = PngInfo()
|
||||
info.add_text("prompt", json.dumps(graph()))
|
||||
info.add_text(KEY, json.dumps(self.extra[KEY]))
|
||||
Image.new("RGB", (2, 2)).save(path, pnginfo=info)
|
||||
parsed = ComfyUIMetadataExtractor().extract_metadata(str(path))
|
||||
self.assertEqual(resolve_prompt_pair(parsed)["positive"]["text"], "cat LORA")
|
||||
with Image.open(path) as image:
|
||||
raw = dict(image.text)
|
||||
raw["prompt"] = json.loads(raw["prompt"])
|
||||
self.assertEqual(resolve_prompt_pair(raw)["positive"]["text"], "cat LORA")
|
||||
|
||||
def test_recording_limits_fail_closed_and_repeat_identical_values_are_safe(self):
|
||||
from utils.pair_execution import MAX_EXECUTION_RECORDS
|
||||
|
||||
self.execute()
|
||||
self.execute()
|
||||
self.assertEqual(self.resolve()["state"], "resolved")
|
||||
for index in range(MAX_EXECUTION_RECORDS + 1):
|
||||
self.execute(text=str(index))
|
||||
self.assertEqual(self.resolve()["state"], "unresolved")
|
||||
|
||||
def test_live_metadata_lookup_uses_unique_execution_for_unknown_source(self):
|
||||
from utils.image_monitor import ImageGenerationHandler
|
||||
|
||||
source = graph(positive=["2", 0])
|
||||
source["2"] = {"class_type": "PromptSearchList", "inputs": {}}
|
||||
self.execute(text="actual")
|
||||
handler = ImageGenerationHandler.__new__(ImageGenerationHandler)
|
||||
handler.db_manager = self.db
|
||||
handler.logger = logging.getLogger("test.execution")
|
||||
state, context = handler._find_prompt_from_metadata(
|
||||
{"prompt": source, **self.extra}
|
||||
)
|
||||
self.assertEqual(state, "linked")
|
||||
expected = self.db.list_prompt_pairs()["pairs"][0]["positive_prompt_id"]
|
||||
self.assertEqual(context["id"], expected)
|
||||
|
||||
def test_submission_clears_old_execution_evidence_without_changing_workflow(self):
|
||||
from utils.usage_tracking import handle_queued_prompt, QueuedRuns
|
||||
|
||||
self.execute()
|
||||
request = {"prompt": graph(), "extra_data": {"extra_pnginfo": self.extra}}
|
||||
handle_queued_prompt(request, QueuedRuns(), pair_queued=QueuedRuns())
|
||||
self.assertNotIn(KEY, self.extra)
|
||||
self.assertEqual(self.extra["workflow"], {"unchanged": True})
|
||||
self.assertEqual(
|
||||
self.resolve()["positive"]["provenance"]["text_source"], "graph-inferred"
|
||||
)
|
||||
|
||||
def test_known_invalid_output_slot_or_cycles_cannot_be_overridden(self):
|
||||
self.execute()
|
||||
source = graph(positive=["2", 4])
|
||||
source["2"] = {"class_type": "PrimitiveString", "inputs": {"value": "cat"}}
|
||||
self.assertEqual(self.resolve(source)["state"], "unresolved")
|
||||
source["2"] = {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {"string_a": ["2", 0], "string_b": "cat"},
|
||||
}
|
||||
source["1"]["inputs"]["text"] = ["2", 0]
|
||||
self.assertEqual(self.resolve(source)["state"], "unresolved")
|
||||
|
||||
def test_live_image_association_keeps_actual_encoding_and_repeated_scan_is_stable(
|
||||
self,
|
||||
):
|
||||
from utils.image_monitor import ImageGenerationHandler
|
||||
from database.pair_ingestion import ingest_resolved_pair
|
||||
|
||||
self.execute()
|
||||
metadata = {"prompt": graph(), **self.extra}
|
||||
handler = ImageGenerationHandler.__new__(ImageGenerationHandler)
|
||||
handler.db_manager = self.db
|
||||
handler.logger = logging.getLogger("test.execution")
|
||||
_, context = handler._find_prompt_from_metadata(metadata)
|
||||
path = str(Path(self.tmp.name) / "actual.png")
|
||||
handler.link_image_to_prompt(path, context, metadata)
|
||||
pair_context = self.db.get_image_prompt_info(path)["pair_context"]
|
||||
self.assertEqual(pair_context["positive_text"], "cat LORA")
|
||||
repeated = ingest_resolved_pair(
|
||||
self.db, path, metadata, resolve_prompt_pair(metadata)
|
||||
)
|
||||
self.assertEqual(repeated["status"], "unchanged")
|
||||
|
||||
def test_unknown_batch_with_multiple_executions_is_not_exactly_linked(self):
|
||||
from utils.image_monitor import ImageGenerationHandler
|
||||
|
||||
source = graph(positive=["2", 0])
|
||||
source["2"] = {"class_type": "PromptSearchList", "inputs": {}}
|
||||
self.execute(text="first")
|
||||
self.execute(text="second")
|
||||
handler = ImageGenerationHandler.__new__(ImageGenerationHandler)
|
||||
handler.db_manager = self.db
|
||||
handler.logger = logging.getLogger("test.execution")
|
||||
state, context = handler._find_prompt_from_metadata(
|
||||
{"prompt": source, **self.extra}
|
||||
)
|
||||
self.assertEqual(state, "unknown")
|
||||
self.assertIsNone(context)
|
||||
|
||||
def test_execution_evidence_bounds_and_malformed_structures_fail_closed(self):
|
||||
from utils import pair_execution as evidence
|
||||
from unittest.mock import patch
|
||||
|
||||
kwargs = dict(
|
||||
text="cat",
|
||||
negative_text="blur",
|
||||
positive_text="cat LORA",
|
||||
negative_effective_text="blur",
|
||||
)
|
||||
evidence.record_pair_execution(None, "1", **kwargs)
|
||||
envelope = {}
|
||||
evidence.record_pair_execution(envelope, None, **kwargs)
|
||||
self.assertEqual(envelope, {})
|
||||
evidence.record_pair_execution(envelope, "1", **kwargs)
|
||||
self.assertIsNone(evidence.read_pair_execution(envelope, "untraced"))
|
||||
malformed = (
|
||||
{"version": 1, "nodes": []},
|
||||
{"version": 1, "truncated": True, "nodes": {}},
|
||||
{"version": 1, "nodes": {"1": []}},
|
||||
{"version": 1, "nodes": {"1": {"records": []}}},
|
||||
{"version": 1, "nodes": {"1": {"records": "bad"}}},
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": {
|
||||
"1": {
|
||||
"records": [
|
||||
{
|
||||
**kwargs,
|
||||
"prepend_text": "",
|
||||
"append_text": "",
|
||||
"negative_effective_text": "changed",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
for value in malformed:
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(evidence.ExecutionEvidenceError):
|
||||
evidence.read_pair_execution({KEY: value}, "1")
|
||||
oversized = {}
|
||||
evidence.record_pair_execution(
|
||||
oversized,
|
||||
"1",
|
||||
**{**kwargs, "text": "x" * (evidence.MAX_EXECUTION_TEXT + 1)},
|
||||
)
|
||||
with self.assertRaises(evidence.ExecutionEvidenceError):
|
||||
evidence.read_pair_execution(oversized, "1")
|
||||
with patch.object(evidence, "MAX_EXECUTION_JSON", 10):
|
||||
with self.assertRaises(evidence.ExecutionEvidenceError):
|
||||
evidence.read_pair_execution({KEY: json.dumps(envelope[KEY])}, "1")
|
||||
limited = {}
|
||||
evidence.record_pair_execution(limited, "1", **kwargs)
|
||||
with self.assertRaises(evidence.ExecutionEvidenceError):
|
||||
evidence.read_pair_execution(limited, "1")
|
||||
with patch.object(evidence, "MAX_EXECUTION_NODES", 1):
|
||||
evidence.record_pair_execution(envelope, "2", **kwargs)
|
||||
evidence.record_pair_execution(envelope, "3", **kwargs)
|
||||
with self.assertRaises(evidence.ExecutionEvidenceError):
|
||||
evidence.read_pair_execution(envelope, "1")
|
||||
|
||||
def test_corrupted_existing_namespace_cannot_break_valid_encoding(self):
|
||||
from utils.pair_execution import (
|
||||
record_pair_execution,
|
||||
read_pair_execution,
|
||||
ExecutionEvidenceError,
|
||||
)
|
||||
|
||||
kwargs = dict(
|
||||
text="cat",
|
||||
negative_text="blur",
|
||||
positive_text="cat LORA",
|
||||
negative_effective_text="blur",
|
||||
)
|
||||
for nodes in ([], {"1": {"records": "bad"}}, {"other": {"bad": object()}}):
|
||||
extra = {KEY: {"version": 1, "nodes": nodes}}
|
||||
record_pair_execution(extra, "1", **kwargs)
|
||||
with self.assertRaises(ExecutionEvidenceError):
|
||||
read_pair_execution(extra, "1")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Live metadata annotation uses the same conservative ingestion as scan."""
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.image_monitor import ImageGenerationHandler
|
||||
|
||||
|
||||
def metadata(positive="cat", negative="blur"):
|
||||
return {
|
||||
"prompt": {
|
||||
"1": {"class_type": "PromptManager", "inputs": {"text": positive}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestPairImageLinking(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.db = PromptDatabase(str(Path(self.temp.name) / "db.sqlite"))
|
||||
self.path = str(Path(self.temp.name) / "image.png")
|
||||
self.pid = self.db.save_prompt("cat", prompt_hash=generate_prompt_hash("cat"))
|
||||
self.handler = ImageGenerationHandler.__new__(ImageGenerationHandler)
|
||||
self.handler.db_manager = self.db
|
||||
self.handler.logger = logging.getLogger("test.pairlink")
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
assert_closed(self, self.temp.name)
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_exact_metadata_creates_pair_link_without_counting_a_run(self):
|
||||
self.handler.link_image_to_prompt(self.path, {"id": self.pid}, metadata())
|
||||
self.handler.link_image_to_prompt(self.path, {"id": self.pid}, metadata())
|
||||
pairs = self.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(len(pairs), 1)
|
||||
self.assertEqual(pairs[0]["image_count"], 1)
|
||||
self.assertEqual(pairs[0]["run_count"], 0)
|
||||
|
||||
def test_stale_context_or_ambiguous_metadata_does_not_create_pair(self):
|
||||
meta = metadata(positive="dog")
|
||||
self.handler.link_image_to_prompt(self.path, {"id": self.pid}, meta)
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
meta = metadata()
|
||||
meta["prompt"]["4"] = meta["prompt"]["3"]
|
||||
self.handler.link_image_to_prompt(self.path + "2", {"id": self.pid}, meta)
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Atomic, repeatable metadata enrichment without changing usage counts."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from database.pair_ingestion import ingest_resolved_pair
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
def resolved(positive="cat", negative="blur"):
|
||||
return {
|
||||
"state": "resolved",
|
||||
"positive": {"text": positive, "provenance": {"source": "api"}},
|
||||
"negative": {"text": negative, "provenance": {"source": "api"}},
|
||||
}
|
||||
|
||||
|
||||
class TestPairIngestion(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.db = PromptDatabase(str(Path(self.temp.name) / "test.db"))
|
||||
self.path = str(Path(self.temp.name) / "image.png")
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
assert_closed(self, self.temp.name)
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_first_scan_repeat_and_negative_variants_reuse_components(self):
|
||||
first = ingest_resolved_pair(self.db, self.path, {}, resolved())
|
||||
self.assertEqual(first["status"], "added")
|
||||
self.assertEqual(first["pairs_added"], 1)
|
||||
second = ingest_resolved_pair(self.db, self.path, {}, resolved())
|
||||
self.assertEqual(second["status"], "unchanged")
|
||||
self.assertEqual(second["pairs_added"], 0)
|
||||
pairs = self.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(len(pairs), 1)
|
||||
self.assertEqual(pairs[0]["image_count"], 1)
|
||||
self.assertEqual(pairs[0]["run_count"], 0)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_hash(generate_prompt_hash("cat"))["run_count"], 0
|
||||
)
|
||||
ingest_resolved_pair(self.db, self.path + "2", {}, resolved(negative="noise"))
|
||||
self.assertEqual(
|
||||
self.db.model.get_connection()
|
||||
.execute("SELECT count(*) FROM prompts")
|
||||
.fetchone()[0],
|
||||
3,
|
||||
)
|
||||
|
||||
def test_existing_image_enriched_in_place_and_metadata_preserved(self):
|
||||
pid = self.db.save_prompt(
|
||||
"cat", category="Personal", prompt_hash=generate_prompt_hash("cat")
|
||||
)
|
||||
image_id = self.db.link_image_to_prompt(pid, self.path)
|
||||
result = ingest_resolved_pair(
|
||||
self.db, self.path, {"prompt": {"a": 1}}, resolved()
|
||||
)
|
||||
self.assertEqual(result["status"], "enriched")
|
||||
self.assertEqual(result["image_id"], image_id)
|
||||
self.assertEqual(self.db.get_prompt_by_id(pid)["category"], "Personal")
|
||||
row = self.db.get_image_by_id(image_id)
|
||||
self.assertIn("a", row["prompt_metadata"])
|
||||
|
||||
def test_conflicting_image_is_not_reassigned_or_duplicated(self):
|
||||
pid = self.db.save_prompt("dog", prompt_hash=generate_prompt_hash("dog"))
|
||||
self.db.link_image_to_prompt(pid, self.path)
|
||||
result = ingest_resolved_pair(self.db, self.path, {}, resolved())
|
||||
self.assertEqual(result["status"], "conflict")
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
self.assertEqual(
|
||||
self.db.model.get_connection()
|
||||
.execute("SELECT count(*) FROM generated_images")
|
||||
.fetchone()[0],
|
||||
1,
|
||||
)
|
||||
|
||||
def test_unknown_and_missing_negative_never_become_empty_pairs(self):
|
||||
for state in ("absent", "ambiguous", "unresolved"):
|
||||
value = resolved()
|
||||
value["state"] = state
|
||||
result = ingest_resolved_pair(self.db, self.path, {}, value)
|
||||
self.assertEqual(result["status"], state)
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
result = ingest_resolved_pair(self.db, self.path, {}, resolved(negative=""))
|
||||
self.assertEqual(result["status"], "added")
|
||||
|
||||
def test_failure_after_inserts_rolls_back_all_rows(self):
|
||||
with patch.object(
|
||||
self.db, "_link_image_to_pair", side_effect=RuntimeError("injected")
|
||||
):
|
||||
with self.assertRaises(RuntimeError):
|
||||
ingest_resolved_pair(self.db, self.path, {}, resolved())
|
||||
conn = self.db.model.get_connection()
|
||||
for table in (
|
||||
"prompts",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"image_prompt_pairs",
|
||||
):
|
||||
self.assertEqual(
|
||||
conn.execute(f"SELECT count(*) FROM {table}").fetchone()[0], 0
|
||||
)
|
||||
|
||||
def test_distinct_paths_are_not_collapsed_and_effective_text_stays_in_provenance(
|
||||
self,
|
||||
):
|
||||
value = resolved("prefix cat suffix")
|
||||
value["positive"]["storage_text"] = "cat"
|
||||
a = ingest_resolved_pair(self.db, self.path, {}, value)
|
||||
b = ingest_resolved_pair(
|
||||
self.db, self.path.replace("image.png", "other/image.png"), {}, value
|
||||
)
|
||||
self.assertEqual(a["pair_id"], b["pair_id"])
|
||||
pair = self.db.get_prompt_pair(a["pair_id"])
|
||||
self.assertEqual(pair["positive_text"], "cat")
|
||||
self.assertEqual(pair["image_count"], 2)
|
||||
association = self.db.get_image_prompt_pairs(a["image_id"])[0]
|
||||
self.assertEqual(
|
||||
association["provenance"]["positive"]["text"], "prefix cat suffix"
|
||||
)
|
||||
|
||||
def test_image_lookup_exposes_its_recorded_pair_not_latest_negative(self):
|
||||
value = resolved("trigger cat", "blur")
|
||||
value["positive"]["storage_text"] = "cat"
|
||||
first = ingest_resolved_pair(self.db, self.path, {}, value)
|
||||
ingest_resolved_pair(self.db, self.path + "2", {}, resolved("cat", "noise"))
|
||||
info = self.db.get_image_prompt_info(self.path)
|
||||
self.assertEqual(info["pair"]["id"], first["pair_id"])
|
||||
self.assertEqual(info["pair"]["negative_text"], "blur")
|
||||
self.assertEqual(info["pair_context"]["positive_text"], "trigger cat")
|
||||
self.assertEqual(info["pair_context"]["negative_text"], "blur")
|
||||
|
||||
def test_overwritten_file_with_different_negative_reports_conflict(self):
|
||||
ingest_resolved_pair(self.db, self.path, {}, resolved())
|
||||
result = ingest_resolved_pair(
|
||||
self.db, self.path, {}, resolved(negative="noise")
|
||||
)
|
||||
self.assertEqual(result["status"], "conflict")
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 1)
|
||||
|
||||
def test_execution_evidence_wins_over_a_prior_inferred_context(self):
|
||||
inferred = resolved()
|
||||
for side in ("positive", "negative"):
|
||||
inferred[side]["provenance"]["text_source"] = "graph-inferred"
|
||||
ingest_resolved_pair(self.db, self.path, {}, inferred)
|
||||
execution = resolved("cat, lora_trigger", "blur")
|
||||
execution["positive"]["storage_text"] = "cat"
|
||||
for side in ("positive", "negative"):
|
||||
execution[side]["provenance"]["text_source"] = "execution"
|
||||
ingest_resolved_pair(self.db, self.path, {}, execution)
|
||||
info = self.db.get_image_prompt_info(self.path)
|
||||
self.assertEqual(info["pair_context"]["positive_text"], "cat, lora_trigger")
|
||||
self.assertEqual(info["pair_context"]["text_source"], "execution")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Actual sampler wires determine which component counts as its positive text."""
|
||||
|
||||
import unittest
|
||||
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.usage_tracking import queued_prompt_runs, queued_pair_runs
|
||||
|
||||
|
||||
def paired_graph(positive_slot=0, negative_slot=1):
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": "cat", "negative_text": "blur"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["3", 0],
|
||||
"positive": ["1", positive_slot],
|
||||
"negative": ["1", negative_slot],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestPairOutputRoles(unittest.TestCase):
|
||||
def test_swapped_conditioning_counts_actual_positive_component_only(self):
|
||||
graph = paired_graph(1, 0)
|
||||
self.assertEqual(
|
||||
queued_prompt_runs(graph), [(generate_prompt_hash("blur"), "blur")]
|
||||
)
|
||||
self.assertEqual(queued_pair_runs(graph), [])
|
||||
|
||||
def test_negative_string_encoded_as_positive_counts_actual_string_component(self):
|
||||
graph = paired_graph()
|
||||
graph["4"] = {"class_type": "CLIPTextEncode", "inputs": {"text": ["1", 3]}}
|
||||
graph["2"]["inputs"]["positive"] = ["4", 0]
|
||||
self.assertEqual(
|
||||
queued_prompt_runs(graph), [(generate_prompt_hash("blur"), "blur")]
|
||||
)
|
||||
self.assertEqual(queued_pair_runs(graph), [])
|
||||
|
||||
def test_two_sampler_positive_roles_count_each_component_once(self):
|
||||
graph = paired_graph()
|
||||
graph["5"] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"model": ["3", 0], "positive": ["1", 1], "negative": ["1", 0]},
|
||||
}
|
||||
self.assertEqual(
|
||||
{text for _, text in queued_prompt_runs(graph)}, {"cat", "blur"}
|
||||
)
|
||||
|
||||
|
||||
class TestPairRoleExecution(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from tests.test_prompt_pair_node import TestPromptPairNode
|
||||
|
||||
TestPromptPairNode.setUp(self)
|
||||
|
||||
def test_new_swapped_component_consumes_its_queued_pending_use(self):
|
||||
self.node.pending_first_use.add(generate_prompt_hash("blur"))
|
||||
self.node.encode_prompt(
|
||||
self.clip, "cat", "blur", prompt=paired_graph(1, 0), unique_id="1"
|
||||
)
|
||||
pair = self.db.list_prompt_pairs()["pairs"][0]
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["negative_prompt_id"])["run_count"], 1
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 0
|
||||
)
|
||||
self.assertEqual(pair["run_count"], 0)
|
||||
|
||||
def test_unknown_negative_link_used_as_positive_counts_at_execution(self):
|
||||
graph = paired_graph(1, 0)
|
||||
graph["1"]["inputs"]["negative_text"] = ["8", 0]
|
||||
graph["8"] = {"class_type": "UnknownBatch", "inputs": {}}
|
||||
self.node.encode_prompt(self.clip, "cat", "blur", prompt=graph, unique_id="1")
|
||||
pair = self.db.list_prompt_pairs()["pairs"][0]
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["negative_prompt_id"])["run_count"], 1
|
||||
)
|
||||
self.assertEqual(pair["run_count"], 0)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Restores preserve pair ownership and bounded, readable JSON provenance."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest.mock
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
|
||||
|
||||
class TestPairRestoreSemantics(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.addCleanup(assert_closed, self, directory.name)
|
||||
self.path = os.path.join(directory.name, "restore.db")
|
||||
self.db = PromptDatabase(self.path)
|
||||
self.addCleanup(self.db.close_all)
|
||||
self.pair_id = self.db.save_prompt_pair("portrait", "blurry")
|
||||
pair = self.db.get_prompt_pair(self.pair_id)
|
||||
self.image_id = self.db.link_image_to_prompt(
|
||||
pair["positive_prompt_id"], os.path.join(directory.name, "image.png")
|
||||
)
|
||||
|
||||
def raw_link(self, provenance, pair_id=None):
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute("DELETE FROM image_prompt_pairs")
|
||||
conn.execute(
|
||||
"INSERT INTO image_prompt_pairs(image_id,pair_id,provenance) "
|
||||
"VALUES(?,?,?)",
|
||||
(self.image_id, pair_id or self.pair_id, provenance),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def test_restore_rejects_pair_with_different_image_positive_owner(self):
|
||||
other = self.db.save_prompt_pair("landscape", "blurry")
|
||||
self.raw_link("{}", other)
|
||||
ok, reason = PromptModel.verify_database_file(self.path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("positive", reason.lower())
|
||||
|
||||
def test_restore_rejects_unreadable_nonobject_and_nonfinite_provenance(self):
|
||||
for value in (
|
||||
"not JSON",
|
||||
"[]",
|
||||
"null",
|
||||
"false",
|
||||
"42",
|
||||
'"text"',
|
||||
'{"value":NaN}',
|
||||
'{"value":Infinity}',
|
||||
'{"value":-Infinity}',
|
||||
'{"value":1e999}',
|
||||
b'{"blob":true}',
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
self.raw_link(value)
|
||||
ok, reason = PromptModel.verify_database_file(self.path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("provenance", reason.lower())
|
||||
|
||||
def test_restore_retains_valid_nested_object_provenance(self):
|
||||
provenance = {"format": "future", "nested": [{"empty": "", "value": 2.5}]}
|
||||
self.raw_link(json.dumps(provenance))
|
||||
self.assertEqual(PromptModel.verify_database_file(self.path), (True, "ok"))
|
||||
self.assertEqual(
|
||||
self.db.get_image_prompt_pairs(self.image_id)[0]["provenance"], provenance
|
||||
)
|
||||
|
||||
def test_restore_rejects_excessive_json_nesting_without_raising(self):
|
||||
self.raw_link('{"nested":' * 1500 + "0" + "}" * 1500)
|
||||
ok, reason = PromptModel.verify_database_file(self.path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("provenance", reason.lower())
|
||||
|
||||
def test_restore_and_runtime_share_the_provenance_size_limit(self):
|
||||
provenance = {"large": "x" * (4_000_000 + 1)}
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.link_image_to_pair(self.pair_id, self.image_id, provenance)
|
||||
self.raw_link(json.dumps(provenance))
|
||||
ok, reason = PromptModel.verify_database_file(self.path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("provenance", reason.lower())
|
||||
|
||||
def test_runtime_normalizes_deep_json_encoding_error(self):
|
||||
provenance = {}
|
||||
child = provenance
|
||||
for _ in range(1500):
|
||||
child["nested"] = {}
|
||||
child = child["nested"]
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.link_image_to_pair(self.pair_id, self.image_id, provenance)
|
||||
self.assertEqual(self.db.get_image_prompt_pairs(self.image_id), [])
|
||||
|
||||
def test_runtime_converts_encoder_recursion_failure_to_validation_error(self):
|
||||
with unittest.mock.patch(
|
||||
"database.pairs.json.dumps", side_effect=RecursionError("depth")
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "JSON"):
|
||||
self.db.link_image_to_pair(self.pair_id, self.image_id, {})
|
||||
|
||||
def test_restore_converts_parser_recursion_failure_to_rejection(self):
|
||||
self.raw_link("{}")
|
||||
with unittest.mock.patch(
|
||||
"database.models.json.loads", side_effect=RecursionError("depth")
|
||||
):
|
||||
ok, reason = PromptModel.verify_database_file(self.path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("provenance", reason.lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Metadata scanning enriches known pairs and conservatively leaves conflicts."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from tests.test_api_admin import AdminAPITestCase
|
||||
|
||||
|
||||
def graph(positive="cat", negative="blur"):
|
||||
return {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestPairScan(AdminAPITestCase):
|
||||
def test_pair_scan_repeated_is_unchanged_and_empty_negative_is_supported(self):
|
||||
path = self.output_dir / "pair.png"
|
||||
meta = {"prompt": json.dumps(graph(negative=""))}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "unchanged")
|
||||
pairs = self.api.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(len(pairs), 1)
|
||||
self.assertEqual(pairs[0]["negative_text"], "")
|
||||
self.assertEqual(pairs[0]["run_count"], 0)
|
||||
|
||||
def test_ambiguous_sampler_scan_does_not_save_a_guess(self):
|
||||
g = graph()
|
||||
g["4"] = {**g["3"]}
|
||||
meta = {"prompt": json.dumps(g)}
|
||||
self.assertEqual(
|
||||
self.api._ingest_scanned_file(self.output_dir / "multi.png", meta),
|
||||
"ambiguous",
|
||||
)
|
||||
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
|
||||
def test_batch_exposes_pair_counts_and_actual_unchanged_status(self):
|
||||
meta = {"parameters": "cat\nNegative prompt: blur\nSteps: 20, Sampler: Euler"}
|
||||
batch = [(Path(self.output_dir / "a.png"), meta)]
|
||||
first = self.api._ingest_scan_batch_sync(batch)
|
||||
self.assertEqual(first["pairs_added"], 1)
|
||||
self.assertEqual(first["pair_links_added"], 1)
|
||||
second = self.api._ingest_scan_batch_sync(batch)
|
||||
self.assertEqual(second["unchanged"], 1)
|
||||
self.assertEqual(second["linked"], 0)
|
||||
self.assertEqual(second["pairs_added"], 0)
|
||||
|
||||
def test_legacy_single_prompt_second_pass_not_reported_as_new_link(self):
|
||||
meta = {"prompt": json.dumps({"1": graph()["1"]})}
|
||||
path = self.output_dir / "old.png"
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "unchanged")
|
||||
|
||||
async def test_cancel_finishes_at_safe_boundary_and_restart_is_idempotent(self):
|
||||
from py.api.scan_job import ScanJob
|
||||
|
||||
job = ScanJob()
|
||||
self.api._scan_job = job
|
||||
response = await self.client.post("/prompt_manager/scan/cancel")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertTrue((await response.json())["cancellation_requested"])
|
||||
counts = self.api._ingest_scan_batch_sync(
|
||||
[(self.output_dir / "cancel.png", {"prompt": json.dumps(graph())})]
|
||||
)
|
||||
self.assertEqual(counts["processed"], 0)
|
||||
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
|
||||
job.finish()
|
||||
self.api._scan_job = ScanJob()
|
||||
first = self.api._ingest_scan_batch_sync(
|
||||
[(self.output_dir / "cancel.png", {"prompt": json.dumps(graph())})]
|
||||
)
|
||||
self.assertEqual(first["pairs_added"], 1)
|
||||
self.assertEqual(
|
||||
self.api._ingest_scan_batch_sync(
|
||||
[(self.output_dir / "cancel.png", {"prompt": json.dumps(graph())})]
|
||||
)["unchanged"],
|
||||
1,
|
||||
)
|
||||
|
||||
def test_legacy_found_outcome_is_not_counted_twice(self):
|
||||
result = self.api._ingest_scan_batch_sync(
|
||||
[(self.output_dir / "blank.png", {"parameters": "Negative prompt: blur"})]
|
||||
)
|
||||
self.assertEqual(result["processed"], 1)
|
||||
self.assertEqual(result["found"], 1)
|
||||
|
||||
def test_ambiguous_a1111_does_not_fall_back_to_legacy_guess(self):
|
||||
meta = {
|
||||
"parameters": (
|
||||
"cat\nNegative prompt: blur\nNegative prompt: noise"
|
||||
"\nSteps: 20, Sampler: Euler"
|
||||
)
|
||||
}
|
||||
self.assertEqual(
|
||||
self.api._ingest_scanned_file(self.output_dir / "ambiguous.png", meta),
|
||||
"ambiguous",
|
||||
)
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
|
||||
def test_raw_png_workflow_is_stored_as_object_and_repeat_is_unchanged(self):
|
||||
path = self.output_dir / "workflow.png"
|
||||
workflow = {"nodes": [{"id": 1, "type": "PromptManagerPair"}]}
|
||||
metadata = {"prompt": json.dumps(graph()), "workflow": json.dumps(workflow)}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, metadata), "added")
|
||||
self.assertEqual(
|
||||
self.api.db.get_image_prompt_info(str(path))["workflow_data"], workflow
|
||||
)
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, metadata), "unchanged")
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Pair schema installation and restore failures use disposable databases only."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.models import PromptModel
|
||||
|
||||
|
||||
class PairSchemaTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.temp.name)
|
||||
self.addCleanup(PromptModel.reset_schema_cache)
|
||||
self.models = []
|
||||
self.addCleanup(self.close_models)
|
||||
self.path = str(Path(self.temp.name) / "live.db")
|
||||
|
||||
def close_models(self):
|
||||
for model in self.models:
|
||||
model.close_all()
|
||||
|
||||
def model(self, path=None):
|
||||
model = PromptModel(path or self.path)
|
||||
self.models.append(model)
|
||||
return model
|
||||
|
||||
def populate(self, model):
|
||||
with model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompts(id,text,hash,category,notes,run_count) VALUES"
|
||||
"(7,'positive','p','keep','notes',12)"
|
||||
)
|
||||
conn.execute("INSERT INTO prompts(id,text,hash) VALUES(9,'negative','n')")
|
||||
conn.execute(
|
||||
"INSERT INTO generated_images(id,prompt_id,image_path,filename,fil"
|
||||
"e_path) VALUES(4,7,'/tmp/image.png','image.png','/tmp/image.png')"
|
||||
)
|
||||
conn.execute("INSERT INTO tags(id,name) VALUES(3,'keep-tag')")
|
||||
conn.execute("INSERT INTO prompt_tags VALUES(7,3)")
|
||||
|
||||
def legacy(self):
|
||||
model = self.model(str(Path(self.temp.name) / "legacy.db"))
|
||||
self.populate(model)
|
||||
with model.get_connection() as conn:
|
||||
for table in ("image_prompt_pairs", "prompt_pair_tags", "prompt_pairs"):
|
||||
conn.execute(f"DROP TABLE IF EXISTS {table}")
|
||||
return model
|
||||
|
||||
def test_restore_scenarios_close_all_sqlite_handles(self):
|
||||
connections = []
|
||||
connect = sqlite3.connect
|
||||
|
||||
def tracked_connect(*args, **kwargs):
|
||||
connection = connect(*args, **kwargs)
|
||||
connections.append(connection)
|
||||
return connection
|
||||
|
||||
scenarios = (
|
||||
"test_pair_ddl_failure_rolls_back_and_retry_succeeds",
|
||||
"test_restore_legacy_wal_migrates_stage_and_preserves_safety_backup",
|
||||
"test_restore_migration_failure_keeps_original_and_source_untouched",
|
||||
"test_verification_rejects_partial_pair_schema_and_orphan_pair",
|
||||
)
|
||||
try:
|
||||
with patch.object(sqlite3, "connect", side_effect=tracked_connect):
|
||||
result = unittest.TestResult()
|
||||
unittest.TestSuite(type(self)(name) for name in scenarios).run(result)
|
||||
self.assertTrue(result.wasSuccessful(), result.errors + result.failures)
|
||||
for index, connection in enumerate(connections):
|
||||
with self.subTest(connection=index):
|
||||
with self.assertRaises(sqlite3.ProgrammingError):
|
||||
connection.execute("SELECT 1")
|
||||
finally:
|
||||
for connection in connections:
|
||||
connection.close()
|
||||
|
||||
def test_fresh_schema_constraints_and_foreign_keys(self):
|
||||
model = self.model()
|
||||
self.populate(model)
|
||||
with model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(id,positive_prompt_id,negative_prompt_id"
|
||||
") VALUES(2,7,9)"
|
||||
)
|
||||
conn.execute("INSERT INTO prompt_pairs(positive_prompt_id) VALUES(7)")
|
||||
conn.execute("INSERT INTO prompt_pair_tags VALUES(2,3)")
|
||||
conn.execute(
|
||||
"INSERT INTO image_prompt_pairs(pair_id,image_id,provenance) VALUE"
|
||||
"S(2,4,'{}')"
|
||||
)
|
||||
for sql in (
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id) V"
|
||||
"ALUES(7,9)",
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id) VALUES(7)",
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id) VALUES(404)",
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,rating) VALUES(9,6)",
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,archived) VALUES(9,2)",
|
||||
"DELETE FROM prompts WHERE id=9",
|
||||
"DELETE FROM prompt_pairs WHERE id=2",
|
||||
"INSERT INTO image_prompt_pairs(pair_id,image_id,provenance) VALUE"
|
||||
"S(2,4,'{}')",
|
||||
):
|
||||
with self.subTest(sql=sql), self.assertRaises(sqlite3.IntegrityError):
|
||||
with model.get_connection() as conn:
|
||||
conn.execute(sql)
|
||||
with model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id) V"
|
||||
"ALUES(9,7)"
|
||||
)
|
||||
conn.execute("DELETE FROM generated_images WHERE id=4")
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) FROM image_prompt_pairs").fetchone()[0], 0
|
||||
)
|
||||
self.assertEqual(conn.execute("PRAGMA foreign_key_check").fetchall(), [])
|
||||
|
||||
def test_legacy_upgrade_preserves_ids_metadata_images_counts_and_repeats(self):
|
||||
legacy = self.legacy()
|
||||
path = legacy.db_path
|
||||
legacy.close_all()
|
||||
PromptModel.reset_schema_cache()
|
||||
for _ in range(2):
|
||||
model = self.model(path)
|
||||
with model.get_connection() as conn:
|
||||
self.assertEqual(
|
||||
tuple(
|
||||
conn.execute(
|
||||
"SELECT id,text,category,notes,run_count FROM prompts WHER"
|
||||
"E id=7"
|
||||
).fetchone()
|
||||
),
|
||||
(7, "positive", "keep", "notes", 12),
|
||||
)
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT id FROM generated_images").fetchone()[0], 4
|
||||
)
|
||||
self.assertEqual(
|
||||
tuple(conn.execute("SELECT * FROM prompt_tags").fetchone()), (7, 3)
|
||||
)
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) FROM prompt_pairs").fetchone()[0], 0
|
||||
)
|
||||
model.close_all()
|
||||
PromptModel.reset_schema_cache()
|
||||
|
||||
def test_pair_ddl_failure_rolls_back_and_retry_succeeds(self):
|
||||
with patch.object(
|
||||
PromptModel,
|
||||
"_create_pair_indexes",
|
||||
side_effect=sqlite3.OperationalError("injected index failure"),
|
||||
):
|
||||
with self.assertRaisesRegex(sqlite3.OperationalError, "injected"):
|
||||
self.model()
|
||||
with closing(sqlite3.connect(self.path)) as conn, conn:
|
||||
self.assertEqual(
|
||||
conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name IN ('prompt_pairs','pro"
|
||||
"mpt_pair_tags','image_prompt_pairs')"
|
||||
).fetchall(),
|
||||
[],
|
||||
)
|
||||
model = self.model()
|
||||
self.assertTrue(PromptModel.verify_database_file(model.db_path)[0])
|
||||
|
||||
def test_verification_rejects_partial_pair_schema_and_orphan_pair(self):
|
||||
model = self.model()
|
||||
self.populate(model)
|
||||
with model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id) V"
|
||||
"ALUES(7,9)"
|
||||
)
|
||||
model.close_all()
|
||||
with closing(sqlite3.connect(model.db_path)) as conn, conn:
|
||||
conn.execute("PRAGMA foreign_keys=OFF")
|
||||
conn.execute("UPDATE prompt_pairs SET negative_prompt_id=404")
|
||||
ok, reason = PromptModel.verify_database_file(model.db_path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("foreign key", reason.lower())
|
||||
with closing(sqlite3.connect(model.db_path)) as conn, conn:
|
||||
conn.execute("DROP TABLE prompt_pair_tags")
|
||||
ok, reason = PromptModel.verify_database_file(model.db_path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("pair", reason.lower())
|
||||
|
||||
def test_restore_migration_failure_keeps_original_and_source_untouched(self):
|
||||
live = self.model()
|
||||
self.populate(live)
|
||||
legacy = self.legacy()
|
||||
with live.get_connection() as conn:
|
||||
conn.execute("UPDATE prompts SET text='original live text' WHERE id=7")
|
||||
with patch.object(
|
||||
PromptModel,
|
||||
"_migrate_prompt_pairs",
|
||||
side_effect=sqlite3.OperationalError("migration failed"),
|
||||
):
|
||||
with self.assertRaisesRegex(sqlite3.OperationalError, "migration failed"):
|
||||
live.restore_from_file(legacy.db_path)
|
||||
with live.get_connection() as conn:
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT text FROM prompts WHERE id=7").fetchone()[0],
|
||||
"original live text",
|
||||
)
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT run_count FROM prompts WHERE id=7").fetchone()[0],
|
||||
12,
|
||||
)
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) FROM generated_images").fetchone()[0], 1
|
||||
)
|
||||
with closing(sqlite3.connect(legacy.db_path)) as conn, conn:
|
||||
self.assertIsNone(
|
||||
conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name='prompt_pairs'"
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
def test_restore_legacy_wal_migrates_stage_and_preserves_safety_backup(self):
|
||||
live = self.model()
|
||||
with live.get_connection() as conn:
|
||||
conn.execute("INSERT INTO prompts(text,hash) VALUES('original','original')")
|
||||
legacy = self.legacy()
|
||||
with legacy.get_connection() as conn:
|
||||
conn.execute("INSERT INTO prompts(text,hash) VALUES('wal-only','wal-only')")
|
||||
self.assertGreater(os.path.getsize(legacy.db_path + "-wal"), 0)
|
||||
backup = live.restore_from_file(legacy.db_path)
|
||||
self.assertTrue(Path(backup).is_file())
|
||||
with closing(sqlite3.connect(backup)) as conn, conn:
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT text FROM prompts").fetchall(), [("original",)]
|
||||
)
|
||||
with live.get_connection() as conn:
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) FROM prompts").fetchone()[0], 3
|
||||
)
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) FROM prompt_pairs").fetchone()[0], 0
|
||||
)
|
||||
self.assertTrue(PromptModel.verify_database_file(live.db_path)[0])
|
||||
|
||||
def test_restore_staged_verification_failure_does_not_replace_live_pages(self):
|
||||
live = self.model()
|
||||
self.populate(live)
|
||||
source = self.legacy()
|
||||
with patch.object(
|
||||
PromptModel,
|
||||
"verify_database_file",
|
||||
side_effect=[(True, "ok"), (False, "staged validation failed")],
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "staged validation failed"):
|
||||
live.restore_from_file(source.db_path)
|
||||
with live.get_connection() as conn:
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT run_count FROM prompts WHERE id=7").fetchone()[0],
|
||||
12,
|
||||
)
|
||||
|
||||
def test_verification_rejects_missing_null_uniqueness(self):
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
conn.execute("DROP INDEX idx_prompt_pairs_positive_only")
|
||||
ok, reason = PromptModel.verify_database_file(model.db_path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("uniqueness", reason)
|
||||
|
||||
def test_restore_round_trips_pair_metadata_tags_and_provenance(self):
|
||||
source = self.model(str(Path(self.temp.name) / "paired.db"))
|
||||
self.populate(source)
|
||||
with source.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(id,positive_prompt_id,negative_prompt_id"
|
||||
",title,notes,run_count,archived) VALUES(6,7,9,'pair title','pair "
|
||||
"notes',19,1)"
|
||||
)
|
||||
conn.execute("INSERT INTO prompt_pair_tags VALUES(6,3)")
|
||||
conn.execute(
|
||||
"INSERT INTO image_prompt_pairs(id,pair_id,image_id,provenance) VA"
|
||||
'LUES(8,6,4,\'{"execution":"kept"}\')'
|
||||
)
|
||||
live = self.model()
|
||||
live.restore_from_file(source.db_path)
|
||||
with live.get_connection() as conn:
|
||||
self.assertEqual(
|
||||
tuple(
|
||||
conn.execute(
|
||||
"SELECT id,title,notes,run_count,archived FROM prompt_pairs"
|
||||
).fetchone()
|
||||
),
|
||||
(6, "pair title", "pair notes", 19, 1),
|
||||
)
|
||||
self.assertEqual(
|
||||
tuple(conn.execute("SELECT * FROM prompt_pair_tags").fetchone()), (6, 3)
|
||||
)
|
||||
self.assertEqual(
|
||||
conn.execute(
|
||||
"SELECT provenance FROM image_prompt_pairs WHERE id=8"
|
||||
).fetchone()[0],
|
||||
'{"execution":"kept"}',
|
||||
)
|
||||
|
||||
def test_restore_rejects_trigger_before_running_migrations(self):
|
||||
live = self.model()
|
||||
source = self.legacy()
|
||||
with source.get_connection() as conn:
|
||||
conn.execute(
|
||||
"CREATE TRIGGER dangerous AFTER INSERT ON prompts BEGIN DELETE FRO"
|
||||
"M prompts; END"
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "trigger"):
|
||||
live.restore_from_file(source.db_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Global tag maintenance preserves pair and standalone tag relationships."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
|
||||
|
||||
class TestPairTagMaintenance(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.addCleanup(assert_closed, self, directory.name)
|
||||
self.db = PromptDatabase(os.path.join(directory.name, "pair-tags.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
|
||||
def pair(self, positive: str, tags: list) -> int:
|
||||
return self.db.save_prompt_pair(positive, "blurry", tags=tags)
|
||||
|
||||
def tags(self, pair_id: int) -> list:
|
||||
return self.db.get_prompt_pair(pair_id)["tags"]
|
||||
|
||||
def test_rename_to_existing_tag_preserves_pair_links_and_deduplicates(self):
|
||||
first = self.pair("portrait", ["old", "new", "keep"])
|
||||
second = self.pair("landscape", ["old"])
|
||||
third = self.pair("studio", ["unrelated"])
|
||||
prompt = self.db.save_prompt("standalone", tags=["old", "new"])
|
||||
result = self.db.rename_tag_all_prompts("old", "new")
|
||||
self.assertEqual(result["affected_count"], 1)
|
||||
self.assertEqual(self.tags(first), ["keep", "new"])
|
||||
self.assertEqual(self.tags(second), ["new"])
|
||||
self.assertEqual(self.tags(third), ["unrelated"])
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["new"])
|
||||
self.assertEqual(self.db.list_prompt_pairs(tags=["new"])["total"], 2)
|
||||
self.assertNotIn("old", self.db.get_all_tags())
|
||||
|
||||
def test_rename_pair_only_tag_to_new_name_preserves_links(self):
|
||||
pair = self.pair("portrait", ["old"])
|
||||
self.db.rename_tag_all_prompts("old", "new")
|
||||
self.assertEqual(self.tags(pair), ["new"])
|
||||
|
||||
def test_merge_pair_only_source_preserves_its_links(self):
|
||||
pair = self.pair("portrait", ["source"])
|
||||
other = self.pair("landscape", ["target"])
|
||||
self.db.merge_tags(["source"], "target")
|
||||
self.assertEqual(self.tags(pair), ["target"])
|
||||
self.assertEqual(self.tags(other), ["target"])
|
||||
self.assertEqual(self.db.list_prompt_pairs(tags=["target"])["total"], 2)
|
||||
|
||||
def test_merge_multiple_sources_preserves_shared_and_unrelated_tags(self):
|
||||
pair = self.pair("portrait", ["first", "second", "target", "keep"])
|
||||
other = self.pair("landscape", ["second"])
|
||||
prompt = self.db.save_prompt("standalone", tags=["first", "second"])
|
||||
self.db.merge_tags(["first", "second"], "target")
|
||||
self.assertEqual(self.tags(pair), ["keep", "target"])
|
||||
self.assertEqual(self.tags(other), ["target"])
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["target"])
|
||||
self.assertEqual(self.db.list_prompt_pairs(tags=["target"])["total"], 2)
|
||||
|
||||
def test_self_rename_does_not_remove_tag_or_relationships(self):
|
||||
pair = self.pair("portrait", ["same"])
|
||||
prompt = self.db.save_prompt("standalone", tags=["same"])
|
||||
self.db.rename_tag_all_prompts("same", "same")
|
||||
self.assertEqual(self.tags(pair), ["same"])
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["same"])
|
||||
|
||||
def test_merge_skips_target_even_when_it_is_in_source_list(self):
|
||||
pair = self.pair("portrait", ["target", "source"])
|
||||
prompt = self.db.save_prompt("standalone", tags=["target", "source"])
|
||||
self.db.merge_tags(["target", "source", "target"], "target")
|
||||
self.assertEqual(self.tags(pair), ["target"])
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["target"])
|
||||
self.assertIn("target", self.db.get_all_tags())
|
||||
|
||||
def test_explicit_global_delete_removes_pair_tags_and_keeps_other_tags(self):
|
||||
pair = self.pair("portrait", ["remove", "keep"])
|
||||
prompt = self.db.save_prompt("standalone", tags=["remove", "keep"])
|
||||
self.db.delete_tag_all_prompts("remove")
|
||||
self.assertEqual(self.tags(pair), ["keep"])
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["keep"])
|
||||
self.assertIsNotNone(self.db.get_prompt_pair(pair))
|
||||
|
||||
def test_pair_link_failure_rolls_back_standalone_merge_and_source_delete(self):
|
||||
pair = self.pair("portrait", ["source"])
|
||||
prompt = self.db.save_prompt("standalone", tags=["source"])
|
||||
conn = self.db.model.get_connection()
|
||||
conn.execute(
|
||||
"CREATE TRIGGER reject_pair_tag_change BEFORE UPDATE "
|
||||
"ON prompt_pair_tags BEGIN SELECT RAISE(ABORT,'pair tag failure'); END"
|
||||
)
|
||||
conn.commit()
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.db.merge_tags(["source"], "target")
|
||||
self.assertEqual(self.tags(pair), ["source"])
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["source"])
|
||||
self.assertNotIn("target", self.db.get_all_tags())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Paired usage is counted at dequeue, including cached node executions."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils import usage_tracking
|
||||
from utils.prompt_graph import run_prompt_nodes
|
||||
from utils.usage_tracking import (
|
||||
PendingFirstUse,
|
||||
QueuedRuns,
|
||||
handle_queued_prompt,
|
||||
on_dequeue,
|
||||
)
|
||||
|
||||
try:
|
||||
from tests.test_prompt_pair_node import pair_graph
|
||||
except ImportError:
|
||||
from test_prompt_pair_node import pair_graph
|
||||
|
||||
|
||||
class TestPairUsageTracking(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.tmp.name)
|
||||
self.db = PromptDatabase(os.path.join(self.tmp.name, "pairs.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
self.queued = QueuedRuns()
|
||||
self.pair_queued = QueuedRuns()
|
||||
self.pending = PendingFirstUse()
|
||||
self.pair_pending = PendingFirstUse()
|
||||
|
||||
def submit(self, graph):
|
||||
return handle_queued_prompt(
|
||||
{"prompt": graph}, self.queued, pair_queued=self.pair_queued
|
||||
)
|
||||
|
||||
def dequeue(self, request):
|
||||
on_dequeue(
|
||||
request["prompt_id"],
|
||||
self.queued,
|
||||
lambda: self.db,
|
||||
self.pending,
|
||||
pair_queued=self.pair_queued,
|
||||
pair_pending=self.pair_pending,
|
||||
)
|
||||
|
||||
def node(self):
|
||||
from prompt_manager_pair import PromptManagerPair
|
||||
|
||||
node = PromptManagerPair.__new__(PromptManagerPair)
|
||||
node.db = self.db
|
||||
node.logger = logging.getLogger("test.pair_usage")
|
||||
node.pending_first_use = self.pending
|
||||
node.pending_pair_first_use = self.pair_pending
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node.prompt_tracker = Mock()
|
||||
node.comfyui_integration = Mock()
|
||||
return node
|
||||
|
||||
def execute(self, graph, text="cat", negative="blur"):
|
||||
return self.node().encode_prompt(
|
||||
Mock(), text, negative_text=negative, prompt=graph, unique_id="1"
|
||||
)
|
||||
|
||||
def test_existing_pair_and_positive_count_on_every_dequeue_without_encoding(self):
|
||||
pid = self.db.save_prompt_pair("cat", "blur")
|
||||
for _ in range(3):
|
||||
self.dequeue(self.submit(pair_graph()))
|
||||
pair = self.db.get_prompt_pair(pid)
|
||||
self.assertEqual(pair["run_count"], 3)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 3
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["negative_prompt_id"])["run_count"], 0
|
||||
)
|
||||
|
||||
def test_validation_rejection_never_counts_or_saves(self):
|
||||
self.submit(pair_graph())
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
self.assertEqual(self.db.search_prompts(), [])
|
||||
|
||||
def test_new_pair_pending_uses_are_consumed_once_when_first_encoding_saves(self):
|
||||
graph = pair_graph()
|
||||
self.dequeue(self.submit(graph))
|
||||
self.dequeue(self.submit(graph))
|
||||
self.execute(graph)
|
||||
pair = self.db.list_prompt_pairs()["pairs"][0]
|
||||
self.assertEqual(pair["run_count"], 2)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 2
|
||||
)
|
||||
self.execute(graph)
|
||||
self.assertEqual(self.db.get_prompt_pair(pair["id"])["run_count"], 2)
|
||||
|
||||
def test_negative_only_changes_create_distinct_pair_counts_and_positive_counts(
|
||||
self,
|
||||
):
|
||||
self.db.save_prompt_pair("cat", "blur")
|
||||
self.db.save_prompt_pair("cat", "watermark")
|
||||
self.dequeue(self.submit(pair_graph()))
|
||||
self.dequeue(self.submit(pair_graph(negative="watermark")))
|
||||
self.assertEqual(
|
||||
[p["run_count"] for p in self.db.list_prompt_pairs()["pairs"]], [1, 1]
|
||||
)
|
||||
positive = self.db.get_prompt_by_hash(
|
||||
usage_tracking.generate_prompt_hash("cat")
|
||||
)
|
||||
self.assertEqual(positive["run_count"], 2)
|
||||
|
||||
def test_negative_output_slots_are_not_positive_prompt_sources(self):
|
||||
for slot in (1, 3):
|
||||
with self.subTest(slot=slot):
|
||||
self.assertEqual(
|
||||
run_prompt_nodes(pair_graph(positive_slot=slot, negative_slot=0)),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_mismatched_negative_does_not_count_unused_intrinsic_pair(self):
|
||||
pid = self.db.save_prompt_pair("cat", "blur")
|
||||
graph = pair_graph()
|
||||
graph["2"] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "another negative"},
|
||||
}
|
||||
graph["5"]["inputs"]["negative"] = ["2", 0]
|
||||
self.dequeue(self.submit(graph))
|
||||
self.execute(graph)
|
||||
self.assertEqual(self.db.get_prompt_pair(pid)["run_count"], 0)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_hash(usage_tracking.generate_prompt_hash("cat"))[
|
||||
"run_count"
|
||||
],
|
||||
1,
|
||||
)
|
||||
|
||||
def test_unknown_batch_text_counts_each_execution_once_and_tracks_fifo(self):
|
||||
graph = pair_graph(text=["2", 0])
|
||||
graph["2"] = {"class_type": "PromptSearchList", "inputs": {}}
|
||||
self.submit(graph)
|
||||
node = self.node()
|
||||
node.encode_prompt(
|
||||
Mock(), "batch cat", negative_text="blur", prompt=graph, unique_id="1"
|
||||
)
|
||||
pair = self.db.list_prompt_pairs()["pairs"][0]
|
||||
self.assertEqual(pair["run_count"], 1)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 1
|
||||
)
|
||||
self.assertTrue(
|
||||
node.prompt_tracker.set_current_prompt.call_args.kwargs["push_to_queue"]
|
||||
)
|
||||
|
||||
def test_standalone_pair_counts_cached_dequeues(self):
|
||||
pid = self.db.save_prompt_pair("cat", "")
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": "cat", "negative_text": ""},
|
||||
}
|
||||
}
|
||||
self.dequeue(self.submit(graph))
|
||||
self.dequeue(self.submit(graph))
|
||||
self.assertEqual(self.db.get_prompt_pair(pid)["run_count"], 2)
|
||||
|
||||
def test_paired_string_outputs_through_encoders_keep_roles(self):
|
||||
from utils.prompt_graph import intrinsic_pair_nodes
|
||||
|
||||
graph = pair_graph()
|
||||
graph["2"] = {"class_type": "CLIPTextEncode", "inputs": {"text": ["1", 2]}}
|
||||
graph["3"] = {"class_type": "CLIPTextEncode", "inputs": {"text": ["1", 3]}}
|
||||
graph["4"] = {"class_type": "Reroute", "inputs": {"value": ["2", 0]}}
|
||||
graph["5"]["inputs"].update(positive=["4", 0], negative=["3", 0])
|
||||
self.assertEqual(intrinsic_pair_nodes(graph), ["1"])
|
||||
pid = self.db.save_prompt_pair("cat", "blur")
|
||||
self.dequeue(self.submit(graph))
|
||||
self.assertEqual(self.db.get_prompt_pair(pid)["run_count"], 1)
|
||||
graph["3"]["inputs"]["text"] = ["1", 2]
|
||||
self.assertEqual(intrinsic_pair_nodes(graph), [])
|
||||
|
||||
def test_two_samplers_using_same_pair_count_one_run_and_dequeue_is_idempotent(self):
|
||||
graph = pair_graph()
|
||||
graph["6"] = {**graph["5"]}
|
||||
pid = self.db.save_prompt_pair("cat", "blur")
|
||||
request = self.submit(graph)
|
||||
self.dequeue(request)
|
||||
self.dequeue(request)
|
||||
self.assertEqual(self.db.get_prompt_pair(pid)["run_count"], 1)
|
||||
|
||||
def test_new_negative_with_existing_positive_counts_pending_pair_only_once(self):
|
||||
self.db.save_prompt_pair("cat", "old negative")
|
||||
graph = pair_graph(negative="new negative")
|
||||
self.dequeue(self.submit(graph))
|
||||
self.execute(graph, negative="new negative")
|
||||
pair = next(
|
||||
p
|
||||
for p in self.db.list_prompt_pairs()["pairs"]
|
||||
if p["negative_text"] == "new negative"
|
||||
)
|
||||
self.assertEqual(pair["run_count"], 1)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 1
|
||||
)
|
||||
|
||||
def test_unknown_negative_counts_at_execution_without_double_positive_count(self):
|
||||
graph = pair_graph(negative=["2", 0])
|
||||
graph["2"] = {"class_type": "PromptSearchList", "inputs": {}}
|
||||
self.dequeue(self.submit(graph))
|
||||
self.execute(graph, negative="batch negative")
|
||||
pair = self.db.list_prompt_pairs()["pairs"][0]
|
||||
self.assertEqual(pair["run_count"], 1)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 1
|
||||
)
|
||||
|
||||
def test_cyclic_unknown_and_oversized_pair_graphs_are_bounded(self):
|
||||
from utils.prompt_graph import intrinsic_pair_nodes
|
||||
from utils.prompt_pair_resolver import resolve_text_input
|
||||
|
||||
self.assertEqual(intrinsic_pair_nodes(None), [])
|
||||
self.assertEqual(intrinsic_pair_nodes({str(n): {} for n in range(501)}), [])
|
||||
graph = pair_graph()
|
||||
graph["2"] = {"class_type": "Reroute", "inputs": {"value": ["2", 0]}}
|
||||
graph["5"]["inputs"]["positive"] = ["2", 0]
|
||||
self.assertEqual(intrinsic_pair_nodes(graph), [])
|
||||
self.assertIsNone(resolve_text_input(None, "1", "text"))
|
||||
self.assertIsNone(resolve_text_input({1: {}, "1": {}}, "1", "text"))
|
||||
self.assertIsNone(resolve_text_input(graph, "missing", "text"))
|
||||
self.assertIsNone(resolve_text_input(pair_graph(), "1", "text", max_depth=0))
|
||||
graph["1"]["inputs"]["text"] = ["1", 3]
|
||||
self.assertEqual(resolve_text_input(graph, "1", "text"), "blur")
|
||||
self.assertIsNone(resolve_text_input(graph, "1", "text", max_text_length=2))
|
||||
|
||||
def test_replacing_client_prompt_id_clears_obsolete_pair_records(self):
|
||||
pid = self.db.save_prompt_pair("cat", "blur")
|
||||
request = {"prompt_id": "same-id", "prompt": pair_graph()}
|
||||
handle_queued_prompt(request, self.queued, pair_queued=self.pair_queued)
|
||||
replacement = pair_graph()
|
||||
replacement["5"]["inputs"]["positive"] = ["1", 1]
|
||||
replacement["5"]["inputs"]["negative"] = ["1", 0]
|
||||
request = {"prompt_id": "same-id", "prompt": replacement}
|
||||
handle_queued_prompt(request, self.queued, pair_queued=self.pair_queued)
|
||||
self.dequeue(request)
|
||||
self.assertEqual(self.db.get_prompt_pair(pid)["run_count"], 0)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Autocomplete shares vocabulary without mixing pair and prompt identities."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class TestPairVocabulary(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.addCleanup(assert_closed, self, directory.name)
|
||||
self.db = PromptDatabase(os.path.join(directory.name, "vocabulary.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
|
||||
def prompt(self, text: str, **kwargs) -> int:
|
||||
return self.db.save_prompt(
|
||||
text, prompt_hash=generate_prompt_hash(text), **kwargs
|
||||
)
|
||||
|
||||
def suggestions(self, prefix="", context=None, limit=20):
|
||||
return self.db.suggest_tags(prefix, context, limit)
|
||||
|
||||
def test_pair_only_categories_and_tags_are_available_without_component_metadata(
|
||||
self,
|
||||
):
|
||||
pair = self.db.get_prompt_pair(
|
||||
self.db.save_prompt_pair(
|
||||
"portrait", "blurry", category="Pairs", tags=["studio"]
|
||||
)
|
||||
)
|
||||
self.assertEqual(self.db.get_all_categories(), ["Pairs"])
|
||||
self.assertEqual(self.db.get_all_tags(), ["studio"])
|
||||
self.assertEqual(self.suggestions("stu"), [{"name": "studio", "count": 1}])
|
||||
for name in ("positive_prompt_id", "negative_prompt_id"):
|
||||
component = self.db.get_prompt_by_id(pair[name])
|
||||
self.assertIsNone(component["category"])
|
||||
self.assertEqual(component["tags"], [])
|
||||
|
||||
def test_categories_are_trimmed_deduplicated_and_include_archived_pairs(self):
|
||||
self.prompt("standalone", category=" Portrait ")
|
||||
pair = self.db.save_prompt_pair(
|
||||
"portrait", "", category="Portrait", tags=["archived"]
|
||||
)
|
||||
self.db.archive_prompt_pair(pair)
|
||||
self.db.save_prompt_pair("landscape", "", category=" Landscape ")
|
||||
self.db.save_prompt_pair("empty", "", category=" ")
|
||||
self.assertEqual(self.db.get_all_categories(), ["Landscape", "Portrait"])
|
||||
self.assertEqual(self.db.get_all_tags(), ["archived"])
|
||||
self.assertEqual(self.suggestions("arc"), [{"name": "archived", "count": 1}])
|
||||
|
||||
def test_counts_include_distinct_pair_and_prompt_references(self):
|
||||
self.prompt("portrait", tags=["shared", "legacy"])
|
||||
self.db.save_prompt_pair(
|
||||
"portrait", "first negative", tags=["shared", "paired"]
|
||||
)
|
||||
self.db.save_prompt_pair("portrait", "second negative", tags=["shared"])
|
||||
self.assertEqual(self.suggestions("sha"), [{"name": "shared", "count": 3}])
|
||||
self.assertEqual(
|
||||
self.suggestions("sha", ["paired"]), [{"name": "shared", "count": 1}]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.suggestions("sha", ["legacy"]), [{"name": "shared", "count": 1}]
|
||||
)
|
||||
|
||||
def test_context_does_not_join_colliding_pair_and_prompt_ids(self):
|
||||
prompt = self.prompt("portrait", tags=["legacy-context", "legacy-only"])
|
||||
pair = self.db.save_prompt_pair(
|
||||
"portrait", "blurry", tags=["pair-context", "pair-only"]
|
||||
)
|
||||
self.assertEqual(prompt, pair)
|
||||
self.assertEqual(
|
||||
self.suggestions("", ["legacy-context"]),
|
||||
[{"name": "legacy-only", "count": 1}],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.suggestions("", ["pair-context"]), [{"name": "pair-only", "count": 1}]
|
||||
)
|
||||
self.assertEqual(self.suggestions("", ["legacy-context", "pair-context"]), [])
|
||||
|
||||
def test_pair_context_matches_all_tags_case_insensitively(self):
|
||||
self.db.save_prompt_pair("portrait", "blurry", tags=["One", "two", "three"])
|
||||
self.db.save_prompt_pair("other", "blurry", tags=["One", "extra"])
|
||||
self.assertEqual(
|
||||
self.suggestions("TH", ["one", "TWO", "one"]),
|
||||
[{"name": "three", "count": 1}],
|
||||
)
|
||||
self.assertEqual(self.suggestions("", ["missing"]), [])
|
||||
|
||||
def test_pair_prefix_wildcards_are_literal_and_limit_is_preserved(self):
|
||||
self.db.save_prompt_pair(
|
||||
"portrait", "", tags=["tag_one", "tag_two", "tag%literal"]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.suggestions("tag_", limit=1), [{"name": "tag_one", "count": 1}]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.suggestions("tag%"), [{"name": "tag%literal", "count": 1}]
|
||||
)
|
||||
self.assertEqual(self.suggestions("%"), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -67,6 +67,19 @@ class TestPositivePromptNodes(unittest.TestCase):
|
||||
del graph["5"]
|
||||
self.assertEqual(run_prompt_nodes(graph), ["2"])
|
||||
|
||||
def test_split_nodes_preserve_positive_roles_and_samplerless_fallback(self):
|
||||
graph = {
|
||||
"1": {"class_type": "PromptManagerSplit", "inputs": {"text": "cat"}},
|
||||
"2": {"class_type": "PromptManagerSplit", "inputs": {"text": "blur"}},
|
||||
"5": ksampler("1", "2"),
|
||||
}
|
||||
self.assertEqual(positive_prompt_nodes(graph), ["1"])
|
||||
self.assertEqual(run_prompt_nodes(graph), ["1"])
|
||||
graph["1"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "cat"}}
|
||||
self.assertEqual(run_prompt_nodes(graph), [])
|
||||
del graph["5"]
|
||||
self.assertEqual(run_prompt_nodes(graph), ["2"])
|
||||
|
||||
def test_negative_only_prompt_manager_is_never_positive(self):
|
||||
graph = {
|
||||
"176": pm("blurry"),
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Paired node encoding preserves recipe storage and output roles."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from prompt_manager_pair import PromptManagerPair
|
||||
from utils.usage_tracking import PendingFirstUse
|
||||
|
||||
|
||||
def pair_graph(text="cat", negative="blur", positive_slot=0, negative_slot=1):
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": text, "negative_text": negative},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["9", 0],
|
||||
"positive": ["1", positive_slot],
|
||||
"negative": ["1", negative_slot],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestPromptPairNode(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.tmp.name)
|
||||
self.db = PromptDatabase(os.path.join(self.tmp.name, "pairs.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
self.node = PromptManagerPair.__new__(PromptManagerPair)
|
||||
self.node.db = self.db
|
||||
self.node.logger = logging.getLogger("test.pair_node")
|
||||
self.node.prompt_tracker = Mock()
|
||||
self.node.comfyui_integration = Mock()
|
||||
self.node._inject_lora_trigger_words = Mock(
|
||||
side_effect=lambda text: text + " LORA"
|
||||
)
|
||||
self.node.pending_first_use = PendingFirstUse()
|
||||
self.node.pending_pair_first_use = PendingFirstUse()
|
||||
self.clip = Mock()
|
||||
self.clip.encode_from_tokens_scheduled.side_effect = lambda tokens: (
|
||||
"conditioning",
|
||||
tokens,
|
||||
)
|
||||
self.clip.tokenize.side_effect = lambda text: text
|
||||
|
||||
def test_schema_has_positive_composition_and_four_ordered_outputs(self):
|
||||
schema = PromptManagerPair.INPUT_TYPES()
|
||||
self.assertEqual(list(schema["required"]), ["text", "negative_text", "clip"])
|
||||
self.assertTrue(schema["required"]["negative_text"][1]["multiline"])
|
||||
self.assertEqual(schema["required"]["negative_text"][1]["default"], "")
|
||||
for name in ("prepend_text", "append_text"):
|
||||
self.assertTrue(schema["optional"][name][1]["forceInput"])
|
||||
self.assertEqual(
|
||||
PromptManagerPair.RETURN_TYPES,
|
||||
("CONDITIONING", "CONDITIONING", "STRING", "STRING"),
|
||||
)
|
||||
self.assertNotEqual(
|
||||
PromptManagerPair.IS_CHANGED(None, "cat", negative_text="blur"),
|
||||
PromptManagerPair.IS_CHANGED(None, "cat", negative_text="watermark"),
|
||||
)
|
||||
|
||||
def test_encodes_each_side_once_positive_composition_and_lora_only(self):
|
||||
graph = pair_graph()
|
||||
result = self.node.encode_prompt(
|
||||
self.clip,
|
||||
"cat",
|
||||
negative_text=" blur ",
|
||||
prepend_text=" trigger ",
|
||||
append_text=" detail ",
|
||||
category="Animals",
|
||||
tags="studio",
|
||||
prompt=graph,
|
||||
unique_id="1",
|
||||
)
|
||||
self.assertEqual(
|
||||
result,
|
||||
(
|
||||
("conditioning", "trigger cat detail LORA"),
|
||||
("conditioning", " blur "),
|
||||
"trigger cat detail LORA",
|
||||
" blur ",
|
||||
),
|
||||
)
|
||||
self.node._inject_lora_trigger_words.assert_called_once_with(
|
||||
"trigger cat detail"
|
||||
)
|
||||
self.assertEqual(self.clip.tokenize.call_count, 2)
|
||||
pair = self.db.list_prompt_pairs()["pairs"][0]
|
||||
self.assertEqual(
|
||||
(
|
||||
pair["positive_text"],
|
||||
pair["negative_text"],
|
||||
pair["category"],
|
||||
pair["tags"],
|
||||
),
|
||||
("cat", "blur", "Animals", ["studio"]),
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["category"]
|
||||
)
|
||||
tracked = self.node.prompt_tracker.set_current_prompt.call_args.kwargs
|
||||
self.assertEqual(tracked["additional_data"]["pair_id"], pair["id"])
|
||||
self.assertEqual(tracked["additional_data"]["negative_text"], " blur ")
|
||||
self.assertEqual(tracked["additional_data"]["positive_storage_text"], "cat")
|
||||
|
||||
def test_empty_negative_encodes_empty_text_and_stores_null_component(self):
|
||||
result = self.node.encode_prompt(self.clip, "cat", negative_text="")
|
||||
self.assertEqual(result[3], "")
|
||||
self.assertIsNone(self.db.list_prompt_pairs()["pairs"][0]["negative_prompt_id"])
|
||||
|
||||
def test_invalid_clip_cannot_save_or_count(self):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.node.encode_prompt(None, "cat", negative_text="blur")
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
self.node.prompt_tracker.set_current_prompt.assert_not_called()
|
||||
|
||||
def test_persistence_failure_does_not_block_conditioning(self):
|
||||
self.node.db = Mock()
|
||||
self.node.db.save_prompt_pair.side_effect = RuntimeError("unavailable")
|
||||
result = self.node.encode_prompt(self.clip, "cat", negative_text="blur")
|
||||
self.assertEqual(result[2:], ("cat LORA", "blur"))
|
||||
self.node.prompt_tracker.set_current_prompt.assert_not_called()
|
||||
|
||||
def test_negative_output_used_as_positive_does_not_claim_positive_usage(self):
|
||||
self.node.encode_prompt(
|
||||
self.clip,
|
||||
"cat",
|
||||
negative_text="blur",
|
||||
prompt=pair_graph(positive_slot=1, negative_slot=0),
|
||||
unique_id="1",
|
||||
)
|
||||
pair = self.db.list_prompt_pairs()["pairs"][0]
|
||||
self.assertEqual(pair["run_count"], 0)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 0
|
||||
)
|
||||
self.node.prompt_tracker.set_current_prompt.assert_not_called()
|
||||
|
||||
def test_invalid_text_does_not_write_and_integration_failures_do_not_block(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.node.encode_prompt(self.clip, [], negative_text="blur")
|
||||
self.node.comfyui_integration.register_prompt.side_effect = RuntimeError(
|
||||
"metadata unavailable"
|
||||
)
|
||||
result = self.node.encode_prompt(self.clip, "cat", negative_text="blur")
|
||||
self.assertEqual(result[2:], ("cat LORA", "blur"))
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Conservative prompt-pair resolution contracts, independent of ComfyUI."""
|
||||
|
||||
import unittest
|
||||
|
||||
from utils.prompt_pair_resolver import OutputAdapter, resolve_prompt_pair
|
||||
|
||||
|
||||
def graph(positive: str = "portrait", negative: str = "blurry") -> dict:
|
||||
return {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestPromptPairResolver(unittest.TestCase):
|
||||
def test_resolves_same_sampler_and_preserves_output_provenance(self):
|
||||
result = resolve_prompt_pair({"prompt": graph()})
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["positive"]["text"], "portrait")
|
||||
self.assertEqual(result["negative"]["text"], "blurry")
|
||||
self.assertEqual(result["positive"]["provenance"]["node_id"], "1")
|
||||
self.assertEqual(result["positive"]["provenance"]["output_slot"], 0)
|
||||
self.assertEqual(result["sampler_id"], "3")
|
||||
|
||||
def test_explicit_empty_negative_is_resolved_but_missing_is_absent(self):
|
||||
self.assertEqual(resolve_prompt_pair(graph(negative=""))["state"], "resolved")
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(graph(negative=""))["negative"]["text"], ""
|
||||
)
|
||||
source = graph()
|
||||
del source["3"]["inputs"]["negative"]
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["state"], "absent")
|
||||
self.assertEqual(result["positive"]["text"], "portrait")
|
||||
self.assertIsNone(result["negative"])
|
||||
|
||||
def test_multiple_samplers_are_ambiguous_even_when_texts_match(self):
|
||||
source = graph()
|
||||
source["4"] = {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"inputs": source["3"]["inputs"],
|
||||
}
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["state"], "ambiguous")
|
||||
self.assertEqual(len(result["candidates"]), 2)
|
||||
self.assertIsNone(result["positive"])
|
||||
|
||||
def test_saver_anchor_selects_only_its_sampler(self):
|
||||
source = graph()
|
||||
source.update(
|
||||
{
|
||||
"4": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["2", 0], "negative": ["1", 0]},
|
||||
},
|
||||
"5": {"class_type": "VAEDecode", "inputs": {"samples": ["4", 0]}},
|
||||
"6": {"class_type": "SaveImage", "inputs": {"images": ["5", 0]}},
|
||||
}
|
||||
)
|
||||
result = resolve_prompt_pair(source, output_node_id="6")
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["sampler_id"], "4")
|
||||
self.assertEqual(result["positive"]["text"], "blurry")
|
||||
|
||||
def test_unknown_anchor_transform_and_multistage_are_unresolved(self):
|
||||
source = graph()
|
||||
source["5"] = {
|
||||
"class_type": "MysteryImageTransform",
|
||||
"inputs": {"images": ["3", 0]},
|
||||
}
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source, output_node_id="5")["state"], "unresolved"
|
||||
)
|
||||
source["4"] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {**source["3"]["inputs"], "latent_image": ["3", 0]},
|
||||
}
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source, output_node_id="4")["state"], "unresolved"
|
||||
)
|
||||
|
||||
def test_combines_unknown_encoders_and_sdxl_channels_are_not_guessed(self):
|
||||
for name in ("ConditioningCombine", "CLIPTextEncodeSDXL", "UnknownEncoder"):
|
||||
with self.subTest(name=name):
|
||||
source = graph()
|
||||
source["1"] = {
|
||||
"class_type": name,
|
||||
"inputs": {
|
||||
"text": "guess",
|
||||
"text_g": "global",
|
||||
"text_l": "local",
|
||||
"conditioning_1": ["2", 0],
|
||||
},
|
||||
}
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_prompt_manager_composition_and_string_slot_are_resolved(self):
|
||||
source = graph()
|
||||
source["8"] = {
|
||||
"class_type": "PromptManagerSplit",
|
||||
"inputs": {
|
||||
"text": "portrait",
|
||||
"prepend_text": " trigger ",
|
||||
"append_text": " detail ",
|
||||
},
|
||||
}
|
||||
source["1"]["inputs"]["text"] = ["8", 1]
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["positive"]["text"], "trigger portrait detail")
|
||||
self.assertIn(
|
||||
{"node_id": "8", "output_slot": 1}, result["positive"]["provenance"]["path"]
|
||||
)
|
||||
source["3"]["inputs"]["positive"] = ["8", 0]
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source)["positive"]["text"], "trigger portrait detail"
|
||||
)
|
||||
source["1"]["inputs"]["text"] = ["8", 0]
|
||||
source["3"]["inputs"]["positive"] = ["1", 0]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_concat_and_reroute_resolve_safe_linked_strings(self):
|
||||
source = graph()
|
||||
source["8"] = {"class_type": "PrimitiveString", "inputs": {"value": "portrait"}}
|
||||
source["9"] = {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {"string_a": ["8", 0], "string_b": ["8", 0], "delimiter": ", "},
|
||||
}
|
||||
source["10"] = {"class_type": "Reroute", "inputs": {"value": ["9", 0]}}
|
||||
source["1"]["inputs"]["text"] = ["10", 0]
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source)["positive"]["text"], "portrait, portrait"
|
||||
)
|
||||
|
||||
def test_slot_adapter_does_not_conflate_two_outputs_of_one_node(self):
|
||||
source = graph()
|
||||
source["8"] = {
|
||||
"class_type": "FuturePair",
|
||||
"inputs": {"first": "positive", "second": "negative"},
|
||||
}
|
||||
source["3"]["inputs"] = {"positive": ["8", 0], "negative": ["8", 1]}
|
||||
adapters = {
|
||||
("FuturePair", 0): OutputAdapter("first", "conditioning"),
|
||||
("FuturePair", 1): OutputAdapter("second", "conditioning"),
|
||||
}
|
||||
result = resolve_prompt_pair(source, adapters=adapters)
|
||||
self.assertEqual(result["positive"]["text"], "positive")
|
||||
self.assertEqual(result["negative"]["text"], "negative")
|
||||
self.assertNotEqual(
|
||||
result["positive"]["provenance"]["output_slot"],
|
||||
result["negative"]["provenance"]["output_slot"],
|
||||
)
|
||||
|
||||
def test_prompt_manager_text_has_only_its_real_string_output_zero(self):
|
||||
source = graph()
|
||||
source["8"] = {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "text node", "prepend_text": "before"},
|
||||
}
|
||||
source["1"]["inputs"]["text"] = ["8", 0]
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source)["positive"]["text"], "before text node"
|
||||
)
|
||||
source["1"]["inputs"]["text"] = ["8", 1]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
source["3"]["inputs"]["positive"] = ["8", 0]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_malformed_class_names_and_unknown_samplers_do_not_claim_absence(self):
|
||||
for name in ([], {}, None, "UnknownSampler"):
|
||||
source = graph()
|
||||
source["3"]["class_type"] = name
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_pair_adapter_composes_only_positive_and_retains_recipe_text(self):
|
||||
source = graph()
|
||||
source["8"] = {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {
|
||||
"text": "positive",
|
||||
"negative_text": "negative",
|
||||
"prepend_text": "trigger",
|
||||
"append_text": "detail",
|
||||
},
|
||||
}
|
||||
source["3"]["inputs"] = {"positive": ["8", 0], "negative": ["8", 1]}
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["positive"]["text"], "trigger positive detail")
|
||||
self.assertEqual(result["positive"]["storage_text"], "positive")
|
||||
self.assertEqual(result["negative"]["text"], "negative")
|
||||
self.assertEqual(result["negative"]["storage_text"], "negative")
|
||||
source["1"]["inputs"]["text"] = ["8", 2]
|
||||
source["2"]["inputs"]["text"] = ["8", 3]
|
||||
source["3"]["inputs"] = {"positive": ["1", 0], "negative": ["2", 0]}
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["positive"]["storage_text"], "positive")
|
||||
self.assertEqual(result["negative"]["text"], "negative")
|
||||
source["3"]["inputs"]["negative"] = ["8", 3]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_linked_recipe_and_ordinary_clip_storage_values_are_explicit(self):
|
||||
source = graph()
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["positive"]["storage_text"], "portrait")
|
||||
source["8"] = {
|
||||
"class_type": "PrimitiveString",
|
||||
"inputs": {"value": "linked main"},
|
||||
}
|
||||
source["1"] = {
|
||||
"class_type": "PromptManager",
|
||||
"inputs": {"text": ["8", 0], "prepend_text": "before"},
|
||||
}
|
||||
result = resolve_prompt_pair(source)
|
||||
self.assertEqual(result["positive"]["text"], "before linked main")
|
||||
self.assertEqual(result["positive"]["storage_text"], "linked main")
|
||||
a1111 = resolve_prompt_pair(
|
||||
{
|
||||
"parameters": (
|
||||
"portrait\nNegative prompt: blurry\n" "Steps: 20, Sampler: Euler"
|
||||
)
|
||||
}
|
||||
)
|
||||
self.assertEqual(a1111["positive"]["storage_text"], "portrait")
|
||||
self.assertEqual(a1111["negative"]["storage_text"], "blurry")
|
||||
|
||||
def test_cycles_depth_visits_and_text_growth_are_bounded(self):
|
||||
source = graph()
|
||||
source["8"] = {"class_type": "Reroute", "inputs": {"value": ["8", 0]}}
|
||||
source["1"]["inputs"]["text"] = ["8", 0]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(graph(), max_visits=1)["state"], "unresolved"
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(graph(), max_depth=0)["state"], "unresolved"
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(graph(), max_text_length=3)["state"], "unresolved"
|
||||
)
|
||||
|
||||
def test_malformed_metadata_and_links_never_raise(self):
|
||||
for value in (
|
||||
None,
|
||||
[],
|
||||
"raw text",
|
||||
{},
|
||||
{"prompt": []},
|
||||
{"prompt": {"1": None}},
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
self.assertIn(
|
||||
resolve_prompt_pair(value)["state"], ("absent", "unresolved")
|
||||
)
|
||||
for link in (["missing", 0], ["1", -1], ["1", True], ["1", 17], ["1"], 5):
|
||||
source = graph()
|
||||
source["3"]["inputs"]["positive"] = link
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_a1111_explicit_parameters_and_empty_negative(self):
|
||||
for negative in ("bad anatomy\nblurry", ""):
|
||||
result = resolve_prompt_pair(
|
||||
{
|
||||
"parameters": (
|
||||
f"portrait\nNegative prompt: {negative}\n"
|
||||
"Steps: 20, Sampler: Euler, Seed: 1"
|
||||
)
|
||||
}
|
||||
)
|
||||
self.assertEqual(result["state"], "resolved")
|
||||
self.assertEqual(result["negative"]["text"], negative)
|
||||
self.assertEqual(result["positive"]["provenance"]["format"], "a1111")
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair({"parameters": "portrait\nSteps: 20, Sampler: Euler"})[
|
||||
"state"
|
||||
],
|
||||
"absent",
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair({"parameters": "unstructured arbitrary text"})["state"],
|
||||
"unresolved",
|
||||
)
|
||||
|
||||
def test_was_concat_preserves_configured_whitespace_and_rejects_bad_delimiters(
|
||||
self,
|
||||
):
|
||||
source = graph()
|
||||
source["8"] = {
|
||||
"class_type": "Text Concatenate",
|
||||
"inputs": {
|
||||
"text_a": " portrait ",
|
||||
"text_b": "",
|
||||
"text_c": "studio",
|
||||
"delimiter": ", ",
|
||||
},
|
||||
}
|
||||
source["1"]["inputs"]["text"] = ["8", 0]
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source)["positive"]["text"], "portrait, studio"
|
||||
)
|
||||
source["8"]["inputs"]["clean_whitespace"] = "false"
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source)["positive"]["text"], " portrait , studio"
|
||||
)
|
||||
source["8"]["inputs"]["delimiter"] = []
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
source["8"]["inputs"] = {1: "bad key"}
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_valid_empty_latent_and_reroute_but_not_unknown_or_cyclic_latents(self):
|
||||
source = graph()
|
||||
source["8"] = {"class_type": "EmptyLatentImage", "inputs": {}}
|
||||
source["9"] = {"class_type": "Reroute", "inputs": {"value": ["8", 0]}}
|
||||
source["3"]["inputs"]["latent_image"] = ["9", 0]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "resolved")
|
||||
source["9"]["inputs"]["value"] = ["9", 0]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
source["3"]["inputs"]["latent_image"] = ["8", 0]
|
||||
source["8"]["class_type"] = "UnknownLatentTransform"
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
source["3"]["inputs"]["latent_image"] = None
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_cyclic_or_invalid_output_anchors_never_guess(self):
|
||||
source = graph()
|
||||
source["8"] = {"class_type": "Reroute", "inputs": {"value": ["8", 0]}}
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source, output_node_id="8")["state"], "unresolved"
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source, output_node_id="missing")["state"], "unresolved"
|
||||
)
|
||||
source["8"] = {"class_type": "SaveImage", "inputs": {"images": ["3", 1]}}
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source, output_node_id="8")["state"], "unresolved"
|
||||
)
|
||||
source["8"] = {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}}
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(source, output_node_id="8", max_depth=1)["state"],
|
||||
"unresolved",
|
||||
)
|
||||
|
||||
def test_malformed_reroute_empty_positive_and_duplicate_string_ids(self):
|
||||
source = graph(positive="")
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
source = graph()
|
||||
source["8"] = {
|
||||
"class_type": "Reroute",
|
||||
"inputs": {"value": ["1", 0], "input": ["2", 0]},
|
||||
}
|
||||
source["3"]["inputs"]["positive"] = ["8", 0]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
source[3] = source["3"]
|
||||
self.assertEqual(resolve_prompt_pair(source)["state"], "unresolved")
|
||||
|
||||
def test_a1111_malformed_ambiguous_and_bounded_parameters(self):
|
||||
self.assertEqual(resolve_prompt_pair({"parameters": None})["state"], "absent")
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair({"parameters": "x" * 20}, max_text_length=10)["state"],
|
||||
"unresolved",
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(
|
||||
{"parameters": "\nNegative prompt: bad\nSteps: 20, Sampler: Euler"}
|
||||
)["state"],
|
||||
"unresolved",
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(
|
||||
{
|
||||
"parameters": (
|
||||
"portrait\nNegative prompt: bad\nNegative prompt: worse\n"
|
||||
"Steps: 20, Sampler: Euler"
|
||||
)
|
||||
}
|
||||
)["state"],
|
||||
"ambiguous",
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(
|
||||
{
|
||||
"parameters": (
|
||||
"portrait\nNegative prompt: bad\n"
|
||||
"Steps: 20, Sampler: Euler\nextra text"
|
||||
)
|
||||
}
|
||||
)["state"],
|
||||
"unresolved",
|
||||
)
|
||||
|
||||
def test_exponential_concat_text_is_bounded_before_building_a_large_string(self):
|
||||
source = graph()
|
||||
source["8"] = {"class_type": "PrimitiveString", "inputs": {"value": "xx"}}
|
||||
for index in range(9, 19):
|
||||
source[str(index)] = {
|
||||
"class_type": "StringConcatenate",
|
||||
"inputs": {
|
||||
"string_a": [str(index - 1), 0],
|
||||
"string_b": [str(index - 1), 0],
|
||||
},
|
||||
}
|
||||
source["1"]["inputs"]["text"] = ["18", 0]
|
||||
result = resolve_prompt_pair(source, max_text_length=100)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
self.assertIn("length limit", result["reason"])
|
||||
self.assertEqual(
|
||||
resolve_prompt_pair(graph(), max_depth=1)["state"], "unresolved"
|
||||
)
|
||||
|
||||
def test_api_graph_is_authoritative_over_unrelated_parameters(self):
|
||||
source = graph()
|
||||
source["1"]["class_type"] = "UnknownEncoder"
|
||||
result = resolve_prompt_pair(
|
||||
{
|
||||
"prompt": source,
|
||||
"parameters": (
|
||||
"fallback\nNegative prompt: wrong\n" "Steps: 20, Sampler: Euler"
|
||||
),
|
||||
}
|
||||
)
|
||||
self.assertEqual(result["state"], "unresolved")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Reusable pairs own their metadata without changing shared prompt content."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class TestPromptPairs(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.tmp.name)
|
||||
self.db = PromptDatabase(os.path.join(self.tmp.name, "pairs.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
|
||||
def save(self, positive="a cat", negative="blurry", **kwargs):
|
||||
return self.db.save_prompt_pair(positive, negative, **kwargs)
|
||||
|
||||
def test_pair_dedup_is_ordered_and_empty_negative_is_explicit(self):
|
||||
first = self.save()
|
||||
self.assertEqual(first, self.save(" A CAT ", " BLURRY "))
|
||||
self.assertNotEqual(first, self.save("blurry", "a cat"))
|
||||
self.assertNotEqual(first, self.save("a cat", "watermark"))
|
||||
empty = self.save("a cat", "")
|
||||
self.assertEqual(empty, self.save("a cat", " "))
|
||||
self.assertIsNone(self.db.get_prompt_pair(empty)["negative_prompt_id"])
|
||||
self.assertEqual(self.db.get_prompt_pair(empty)["negative_text"], "")
|
||||
self.assertNotEqual(empty, first)
|
||||
|
||||
def test_same_text_can_serve_both_roles_and_existing_metadata_survives(self):
|
||||
prompt = self.db.save_prompt(
|
||||
"a cat",
|
||||
category="original",
|
||||
tags=["old"],
|
||||
prompt_hash=generate_prompt_hash("a cat"),
|
||||
)
|
||||
pair = self.db.get_prompt_pair(
|
||||
self.save("a cat", "a cat", category="pair", tags=["new"])
|
||||
)
|
||||
self.assertEqual(pair["positive_prompt_id"], prompt)
|
||||
self.assertEqual(pair["negative_prompt_id"], prompt)
|
||||
self.assertEqual(pair["tags"], ["new"])
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["category"], "original")
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt)["tags"], ["old"])
|
||||
|
||||
def test_repeated_save_preserves_pair_metadata_and_explicit_update_changes_it(self):
|
||||
pid = self.save(
|
||||
title="Title",
|
||||
category="Portrait",
|
||||
tags=["studio", "studio"],
|
||||
rating=4,
|
||||
notes="keep",
|
||||
)
|
||||
self.assertEqual(pid, self.save(title="overwrite", tags=["bad"], rating=1))
|
||||
pair = self.db.get_prompt_pair(pid)
|
||||
self.assertEqual(
|
||||
(
|
||||
pair["title"],
|
||||
pair["category"],
|
||||
pair["tags"],
|
||||
pair["rating"],
|
||||
pair["notes"],
|
||||
),
|
||||
("Title", "Portrait", ["studio"], 4, "keep"),
|
||||
)
|
||||
self.assertTrue(
|
||||
self.db.update_prompt_pair_metadata(
|
||||
pid, title="", category="", tags=[], notes=""
|
||||
)
|
||||
)
|
||||
pair = self.db.get_prompt_pair(pid)
|
||||
self.assertEqual(
|
||||
(pair["title"], pair["category"], pair["tags"], pair["notes"]),
|
||||
("", "", [], ""),
|
||||
)
|
||||
self.assertEqual(pair["rating"], 4)
|
||||
self.assertFalse(self.db.update_prompt_pair_metadata(999999, title="missing"))
|
||||
self.assertFalse(self.db.update_prompt_pair_metadata(pid))
|
||||
|
||||
def test_list_filters_pagination_archive_and_pair_usage_are_independent(self):
|
||||
first = self.save(category="Animals", tags=["studio", "cat"])
|
||||
second = self.save("a dog", "blur", category="Animals", tags=["studio"])
|
||||
other = self.save("sky", "", category="Outdoor")
|
||||
result = self.db.list_prompt_pairs(category="Animals", tags=["studio"], limit=1)
|
||||
self.assertEqual(result["total"], 2)
|
||||
self.assertTrue(result["has_more"])
|
||||
self.assertEqual(len(result["pairs"]), 1)
|
||||
self.assertEqual(
|
||||
self.db.list_prompt_pairs(text="dog")["pairs"][0]["id"], second
|
||||
)
|
||||
self.assertEqual(self.db.list_prompt_pairs(tags=["studio", "cat"])["total"], 1)
|
||||
self.assertTrue(self.db.archive_prompt_pair(first))
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 2)
|
||||
self.assertEqual(self.db.list_prompt_pairs(include_archived=True)["total"], 3)
|
||||
self.assertTrue(self.db.get_prompt_pair(first)["archived"])
|
||||
self.assertTrue(self.db.archive_prompt_pair(first, False))
|
||||
self.assertTrue(self.db.record_prompt_pair_use(other, 2))
|
||||
pair = self.db.get_prompt_pair(other)
|
||||
self.assertEqual(pair["run_count"], 2)
|
||||
self.assertIsNotNone(pair["last_used_at"])
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(pair["positive_prompt_id"])["run_count"], 0
|
||||
)
|
||||
|
||||
def test_failure_during_tag_save_rolls_back_pair_and_components(self):
|
||||
with patch.object(
|
||||
self.db, "_ensure_tags", side_effect=sqlite3.OperationalError("injected")
|
||||
):
|
||||
with self.assertRaises(sqlite3.OperationalError):
|
||||
self.save(tags=["studio"])
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
self.assertEqual(self.db.search_prompts(), [])
|
||||
|
||||
def test_concurrent_same_pair_returns_one_id_and_preserves_first_metadata(self):
|
||||
first = self.save(title="original")
|
||||
|
||||
def save(index):
|
||||
try:
|
||||
return self.save(title=f"writer {index}")
|
||||
finally:
|
||||
self.db.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=6) as executor:
|
||||
ids = list(executor.map(save, range(24)))
|
||||
self.assertEqual(set(ids), {first})
|
||||
self.assertEqual(self.db.get_prompt_pair(first)["title"], "original")
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 1)
|
||||
|
||||
def test_concurrent_empty_negative_pair_creation_is_atomic(self):
|
||||
def save(_):
|
||||
try:
|
||||
return self.save(negative="")
|
||||
finally:
|
||||
self.db.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=6) as executor:
|
||||
ids = list(executor.map(save, range(12)))
|
||||
self.assertEqual(len(set(ids)), 1)
|
||||
self.assertEqual(len(self.db.search_prompts()), 1)
|
||||
|
||||
def test_component_edit_and_all_delete_paths_reject_dependencies_atomically(self):
|
||||
from database.operations import PairDependencyError
|
||||
|
||||
pair = self.db.get_prompt_pair(self.save())
|
||||
positive = pair["positive_prompt_id"]
|
||||
image = self.db.link_image_to_prompt(
|
||||
positive, os.path.join(self.tmp.name, "keep.png")
|
||||
)
|
||||
unused = self.db.save_prompt(
|
||||
"unused", category="remove", prompt_hash=generate_prompt_hash("unused")
|
||||
)
|
||||
self.db.update_prompt_metadata(positive, category="remove")
|
||||
for action in (
|
||||
lambda: self.db.update_prompt_text(positive, "replacement"),
|
||||
lambda: self.db.delete_prompt(positive),
|
||||
lambda: self.db.bulk_delete_prompts([unused, positive]),
|
||||
lambda: self.db.delete_prompts_by_category("remove"),
|
||||
):
|
||||
with self.subTest(action=action):
|
||||
with self.assertRaises(PairDependencyError):
|
||||
action()
|
||||
self.assertIsNotNone(self.db.get_prompt_by_id(unused))
|
||||
self.assertEqual(self.db.get_prompt_by_id(positive)["text"], "a cat")
|
||||
self.assertIsNotNone(self.db.get_image_by_id(image))
|
||||
self.assertTrue(self.db.delete_prompt_pair(pair["id"]))
|
||||
self.assertTrue(self.db.update_prompt_text(positive, "replacement"))
|
||||
|
||||
def test_prune_skips_members_even_in_archived_pairs_and_preserves_other_behavior(
|
||||
self,
|
||||
):
|
||||
pair = self.db.get_prompt_pair(self.save())
|
||||
self.db.archive_prompt_pair(pair["id"])
|
||||
unused = self.db.save_prompt(
|
||||
"unused", prompt_hash=generate_prompt_hash("unused")
|
||||
)
|
||||
self.assertEqual(self.db.prune_orphaned_prompts(), 1)
|
||||
self.assertIsNone(self.db.get_prompt_by_id(unused))
|
||||
self.assertIsNotNone(self.db.get_prompt_by_id(pair["negative_prompt_id"]))
|
||||
|
||||
def test_duplicate_cleanup_skips_referenced_group_without_losing_pairs(self):
|
||||
pair = self.db.get_prompt_pair(self.save())
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompts(text,category) VALUES(' A CAT ', 'duplicate')"
|
||||
)
|
||||
self.assertEqual(self.db.cleanup_duplicates(), 0)
|
||||
self.assertEqual(self.db.get_prompt_pair(pair["id"])["positive_text"], "a cat")
|
||||
self.assertEqual(len(self.db.search_prompts()), 3)
|
||||
|
||||
def test_image_association_preserves_provenance_and_pair_deletion_requires_archive(
|
||||
self,
|
||||
):
|
||||
from database.operations import PairDependencyError
|
||||
|
||||
pid = self.save()
|
||||
pair = self.db.get_prompt_pair(pid)
|
||||
image = self.db.link_image_to_prompt(
|
||||
pair["positive_prompt_id"], os.path.join(self.tmp.name, "render.png")
|
||||
)
|
||||
provenance = {
|
||||
"run_id": "abc",
|
||||
"negative_text": "actual expanded negative",
|
||||
"positive_text": "trigger a cat",
|
||||
}
|
||||
association = self.db.link_image_to_pair(pid, image, provenance)
|
||||
self.assertEqual(
|
||||
association,
|
||||
self.db.link_image_to_pair(
|
||||
pid, image, dict(reversed(list(provenance.items())))
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.get_image_prompt_pairs(image)[0]["provenance"], provenance
|
||||
)
|
||||
self.assertEqual(self.db.get_prompt_pair(pid)["image_count"], 1)
|
||||
with self.assertRaises(PairDependencyError):
|
||||
self.db.delete_prompt_pair(pid)
|
||||
self.assertTrue(self.db.archive_prompt_pair(pid))
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.link_image_to_pair(99999, image, provenance)
|
||||
self.assertTrue(self.db.delete_image(image))
|
||||
self.assertEqual(self.db.get_image_prompt_pairs(image), [])
|
||||
self.assertTrue(self.db.delete_prompt_pair(pid))
|
||||
self.assertIsNotNone(self.db.get_prompt_by_id(pair["positive_prompt_id"]))
|
||||
|
||||
def test_pair_validates_values_before_writing(self):
|
||||
for positive, negative, kwargs in (
|
||||
("", "blur", {}),
|
||||
("cat", None, {}),
|
||||
("cat", "blur", {"rating": 6}),
|
||||
("cat", "blur", {"tags": "one"}),
|
||||
):
|
||||
with self.subTest(positive=positive, negative=negative, kwargs=kwargs):
|
||||
with self.assertRaises((TypeError, ValueError)):
|
||||
self.save(positive, negative, **kwargs)
|
||||
self.assertEqual(self.db.search_prompts(), [])
|
||||
for kwargs in ({"limit": 0}, {"offset": -1}, {"limit": True}):
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.list_prompt_pairs(**kwargs)
|
||||
self.assertIsNone(self.db.get_prompt_pair(99999))
|
||||
self.assertFalse(self.db.delete_prompt_pair(99999))
|
||||
|
||||
def test_connection_helpers_leave_commit_and_rollback_to_the_caller(self):
|
||||
conn = self.db.model.get_connection()
|
||||
|
||||
def rollback_transaction():
|
||||
with conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
self.db._save_pair(conn, "transient", "temporary", tags=["tag"])
|
||||
self.assertRaisesRegex(RuntimeError, "own transaction", self.save)
|
||||
raise RuntimeError("rollback")
|
||||
|
||||
self.assertRaisesRegex(RuntimeError, "rollback", rollback_transaction)
|
||||
self.assertEqual(self.db.list_prompt_pairs()["total"], 0)
|
||||
self.assertEqual(self.db.search_prompts(), [])
|
||||
|
||||
def test_metadata_update_failure_rolls_back_prior_fields_and_tags(self):
|
||||
pid = self.save(title="keep", tags=["old"])
|
||||
with patch.object(
|
||||
self.db, "_ensure_tags", side_effect=sqlite3.OperationalError("injected")
|
||||
):
|
||||
with self.assertRaises(sqlite3.OperationalError):
|
||||
self.db.update_prompt_pair_metadata(pid, title="replace", tags=["new"])
|
||||
pair = self.db.get_prompt_pair(pid)
|
||||
self.assertEqual(pair["title"], "keep")
|
||||
self.assertEqual(pair["tags"], ["old"])
|
||||
|
||||
def test_invalid_associations_metadata_and_missing_mutations_are_safe(self):
|
||||
pid = self.save()
|
||||
other = self.db.save_prompt("other", prompt_hash=generate_prompt_hash("other"))
|
||||
image = self.db.link_image_to_prompt(
|
||||
other, os.path.join(self.tmp.name, "other.png")
|
||||
)
|
||||
for provenance in ([], {"number": float("nan")}, {"object": object()}):
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.link_image_to_pair(pid, image, provenance)
|
||||
with self.assertRaisesRegex(ValueError, "positive prompt"):
|
||||
self.db.link_image_to_pair(pid, image, {})
|
||||
self.assertEqual(self.db.get_image_prompt_pairs(image), [])
|
||||
self.assertFalse(self.db.archive_prompt_pair(999999))
|
||||
self.assertFalse(self.db.record_prompt_pair_use(999999))
|
||||
for times in (0, True):
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.record_prompt_pair_use(pid, times)
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.archive_prompt_pair(pid, "yes")
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.update_prompt_pair_metadata(pid, title=[])
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.list_prompt_pairs(text=[])
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Split changes presentation while inheriting Focused execution and storage."""
|
||||
|
||||
import logging
|
||||
import unittest
|
||||
import unittest.mock
|
||||
|
||||
from prompt_manager import PromptManagerFocused, PromptManagerSplit
|
||||
|
||||
|
||||
class TestSplitNode(unittest.TestCase):
|
||||
def test_split_preserves_focused_schema_encoding_and_change_detection(self):
|
||||
self.assertTrue(issubclass(PromptManagerSplit, PromptManagerFocused))
|
||||
self.assertEqual(
|
||||
PromptManagerSplit.INPUT_TYPES(), PromptManagerFocused.INPUT_TYPES()
|
||||
)
|
||||
self.assertEqual(
|
||||
PromptManagerSplit.RETURN_TYPES, PromptManagerFocused.RETURN_TYPES
|
||||
)
|
||||
self.assertEqual(PromptManagerSplit.FUNCTION, PromptManagerFocused.FUNCTION)
|
||||
self.assertIs(
|
||||
PromptManagerSplit.encode_prompt, PromptManagerFocused.encode_prompt
|
||||
)
|
||||
self.assertEqual(
|
||||
PromptManagerSplit.IS_CHANGED(None, "cat", category="Animals"),
|
||||
PromptManagerFocused.IS_CHANGED(None, "cat", category="Animals"),
|
||||
)
|
||||
|
||||
def test_split_encodes_composition_and_persists_main_text_with_metadata(self):
|
||||
node = PromptManagerSplit.__new__(PromptManagerSplit)
|
||||
node.logger = logging.getLogger("test.split")
|
||||
node.comfyui_integration = unittest.mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node._track_prompt_execution = unittest.mock.Mock(return_value=42)
|
||||
clip = unittest.mock.Mock()
|
||||
graph = {"1": {"class_type": "PromptManagerSplit", "inputs": {"text": "cat"}}}
|
||||
conditioning, text = node.encode_prompt(
|
||||
clip,
|
||||
"cat",
|
||||
category="Animals",
|
||||
tags="studio",
|
||||
prepend_text="trigger",
|
||||
append_text="detailed",
|
||||
prompt=graph,
|
||||
unique_id="1",
|
||||
)
|
||||
self.assertEqual(text, "trigger cat detailed")
|
||||
clip.tokenize.assert_called_once_with(text)
|
||||
self.assertIs(conditioning, clip.encode_from_tokens_scheduled.return_value)
|
||||
tracked = node._track_prompt_execution.call_args.kwargs
|
||||
self.assertEqual(tracked["text"], "cat")
|
||||
self.assertEqual(tracked["category"], "Animals")
|
||||
self.assertIn("studio", tracked["tags"])
|
||||
self.assertEqual(tracked["prompt_graph"], graph)
|
||||
self.assertEqual(tracked["unique_id"], "1")
|
||||
@@ -136,6 +136,16 @@ class TestQueueHook(DbTestCase):
|
||||
self.assertEqual(self._runs(positive), 2)
|
||||
self.assertEqual(self._runs(negative), 0)
|
||||
|
||||
def test_split_queued_runs_count_only_positive_even_when_cached(self):
|
||||
positive, negative = self._save("a cat"), self._save("blurry")
|
||||
graph = workflow()
|
||||
for node_id in ("134", "176"):
|
||||
graph[node_id]["class_type"] = "PromptManagerSplit"
|
||||
self._queue(graph)
|
||||
self._queue(graph)
|
||||
self.assertEqual(self._runs(positive), 2)
|
||||
self.assertEqual(self._runs(negative), 0)
|
||||
|
||||
def test_requeue_counts_again_even_when_comfyui_skips_the_cached_node(self):
|
||||
positive = self._save("a cat")
|
||||
self._queue(workflow())
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Serve only UI concepts and their pinned toolkit and viewer assets."""
|
||||
|
||||
import argparse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FILES = {
|
||||
"/": ("docs/concepts/prompt-variant-timeline.html", "text/html; charset=utf-8"),
|
||||
"/workspace/": ("docs/concepts/workspace/index.html", "text/html; charset=utf-8"),
|
||||
"/workspace/style.css": ("docs/concepts/workspace/style.css", "text/css"),
|
||||
"/workspace/app.js": ("docs/concepts/workspace/app.js", "text/javascript"),
|
||||
"/vendor/viewer.js": ("web/lib/viewerjs/viewer.min.js", "text/javascript"),
|
||||
"/vendor/viewer.css": ("web/lib/viewerjs/viewer.min.css", "text/css"),
|
||||
"/toolkit/core.js": ("web/js/shared/toolkit/core.js", "text/javascript"),
|
||||
"/toolkit/controls.css": ("web/js/shared/toolkit/controls.css", "text/css"),
|
||||
}
|
||||
|
||||
|
||||
class PreviewHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
target = FILES.get(urlsplit(self.path).path)
|
||||
if target is None:
|
||||
self.send_error(404)
|
||||
return
|
||||
filename, mime = target
|
||||
body = (ROOT / filename).read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", mime)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--bind", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8766)
|
||||
args = parser.parse_args()
|
||||
server = ThreadingHTTPServer((args.bind, args.port), PreviewHandler)
|
||||
print(f"Timeline prototype: http://{args.bind}:{args.port}/", flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
# Ctrl+C exits normally; the finally block closes the listening socket.
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
@@ -136,5 +136,23 @@
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/index.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/theme.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ from .hashing import generate_prompt_hash
|
||||
from .metadata_extractor import ComfyUIMetadataExtractor
|
||||
from .logging_config import get_logger
|
||||
from .prompt_graph import resolve_text, run_prompt_nodes
|
||||
from .prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
try:
|
||||
from ..database.pair_ingestion import ingest_resolved_pair
|
||||
except ImportError:
|
||||
from database.pair_ingestion import ingest_resolved_pair
|
||||
|
||||
# Upper bound on images waiting to be processed (a burst beyond this is skipped)
|
||||
MAX_PENDING_IMAGES = 1000
|
||||
@@ -380,6 +386,29 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
return "unknown", None
|
||||
|
||||
prompt_text = None
|
||||
resolution = resolve_prompt_pair(metadata)
|
||||
graph = metadata.get("prompt")
|
||||
if resolution["state"] == "resolved" and isinstance(graph, dict):
|
||||
positive = resolution["positive"]
|
||||
path = positive["provenance"].get("path", [])
|
||||
paired_source = any(
|
||||
isinstance(graph.get(item.get("node_id")), dict)
|
||||
and graph[item["node_id"]].get("class_type") == "PromptManagerPair"
|
||||
for item in path
|
||||
)
|
||||
if paired_source:
|
||||
try:
|
||||
existing = self.db_manager.get_prompt_by_hash(
|
||||
generate_prompt_hash(positive["storage_text"])
|
||||
)
|
||||
if existing:
|
||||
return "linked", {
|
||||
"id": existing["id"],
|
||||
"text": existing["text"],
|
||||
"from_metadata": True,
|
||||
}
|
||||
except Exception as error:
|
||||
self.logger.warning(f"Paired metadata lookup failed: {error}")
|
||||
|
||||
# The executed graph: use the PromptManager node feeding the positive input
|
||||
prompt_data = metadata.get("prompt")
|
||||
@@ -529,6 +558,17 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
etc.)
|
||||
"""
|
||||
try:
|
||||
resolution = resolve_prompt_pair(metadata)
|
||||
if resolution["state"] == "resolved":
|
||||
positive = resolution["positive"]
|
||||
expected = self.db_manager.get_prompt_by_hash(
|
||||
generate_prompt_hash(positive.get("storage_text", positive["text"]))
|
||||
)
|
||||
if expected and expected["id"] == prompt_context["id"]:
|
||||
ingest_resolved_pair(
|
||||
self.db_manager, image_path, metadata, resolution
|
||||
)
|
||||
return
|
||||
image_id = self.db_manager.link_image_to_prompt(
|
||||
prompt_id=prompt_context["id"], image_path=image_path, metadata=metadata
|
||||
)
|
||||
|
||||