Files
ComfyAssets-ComfyUI_PromptM…/tests/test_api_maintenance_pair_metadata.py
T
Vito Sansevero 7cecfa6b58 feat(maintenance): repair old Pair prompts' empty category and tags (#550)
Adds System > Maintenance > "Pair category and tags" (pair_metadata), a
reviewed plan that fills the empty category and empty tag set of prompts
Pair node runs created before #546, from their non-archived saved pairs.

- Only Pair-created prompts (earliest pair has them as the positive, saved
  within 2 s), only empty fields, only when every pair with a value agrees.
- Archived, edited (updated_at not within 2 s of created_at), conflicting
  and over-20-tag fields are kept and listed first in the plan.
- Apply recomputes the fingerprint inside BEGIN IMMEDIATE and writes guarded
  statements; a guard that hits nothing rolls everything back. Never writes
  prompt_pairs, prompt_pair_tags, updated_at, tags or Library state.
- Follows the safety backup policy like the other operations.
- The job's downloadable report lists every filled prompt (no 200-row cap):
  Job.record_counted(complete=True).
- D192: the row beyond the concept, mapped in the concept comparison.
2026-10-31 15:26:50 -07:00

141 lines
6.2 KiB
Python

"""Pair category and tags repair through the maintenance routes (#550).
``pair_metadata`` is reviewed and applied like the other maintenance plans:
``options`` must be ``{}``, the job is titled "Pair category and tags", the
safety backup follows the ``maintenance.safety_backup`` policy, and the job's
downloadable report lists every filled prompt, never cut at 200 rows.
"""
import asyncio
import os
import sys
from unittest.mock import MagicMock
_mock_server = MagicMock()
_mock_server.PromptServer.instance.routes = MagicMock()
sys.modules.setdefault("server", _mock_server)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from py.api.jobs import MAX_OUTCOME_ROWS # noqa: E402
from py.api.maintenance import MAINTENANCE_TITLES # noqa: E402
from py.config import PromptManagerConfig # noqa: E402
try:
from tests.test_api_maintenance import WAIT, ApplyTestCase # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from test_api_maintenance import WAIT, ApplyTestCase # noqa: E402
OPERATION = "pair_metadata"
TITLE = "Pair category and tags"
class PairMetadataAPITestCase(ApplyTestCase):
def pair(self, positive, negative="", **metadata):
pair_id = self.api.db.save_prompt_pair(positive, negative, **metadata)
with self.api.db.model.get_connection() as conn:
prompt = conn.execute(
"SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", (pair_id,)
).fetchone()[0]
return pair_id, prompt
def category(self, prompt_id):
with self.api.db.model.get_connection() as conn:
return conn.execute(
"SELECT category FROM prompts WHERE id=?", (prompt_id,)
).fetchone()[0]
class TestPairMetadataRoutes(PairMetadataAPITestCase):
async def test_preview_accepts_the_operation_and_no_options(self):
self.assertEqual(MAINTENANCE_TITLES[OPERATION], TITLE)
self.pair("a cat", category="portrait", tags=["a"])
plan = await self.preview(OPERATION)
self.assertEqual(plan["operation"], OPERATION)
self.assertEqual(plan["scope"]["count"], 1)
self.assertEqual(plan["backup"]["policy"], "on")
refused = await self.preview(OPERATION, 400, options={"all": True})
self.assertEqual(refused["code"], "invalid-request")
async def test_apply_fills_with_a_backup_and_names_it(self):
_pair, prompt = self.pair("a cat", "neg", category="portrait", tags=["a", "b"])
plan, summary, job = await self.run_apply(OPERATION)
self.assertEqual(summary["title"], TITLE)
self.assertEqual(summary["admission"], "records")
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(self.category(prompt), "portrait")
[backup] = self.backups()
self.assertEqual(
job.status_line,
"Filled 1 prompt from its saved pairs (1 category, 1 tag set) · "
f"Safety backup {backup}",
)
self.assertEqual(
job.outcomes["changed"][0]["detail"],
f"Prompt {prompt}: category “portrait”; tags a, b",
)
self.assertEqual(job.counters["changed"], 1)
async def test_protected_prompts_are_counted(self):
_a, prompt = self.pair("conflict", "n1", category="x")
self.pair("conflict", "n2", category="y")
self.pair("fill me", category="portrait")
plan, _summary, job = await self.run_apply(OPERATION)
self.assertEqual(plan["protected"]["reasons"], ["1 where pairs disagree"])
self.assertEqual(job.counters["protected"], 1)
self.assertIsNone(self.category(prompt))
async def test_policy_off_writes_without_a_backup(self):
PromptManagerConfig.SAFETY_BACKUP = False
_pair, prompt = self.pair("no backup", category="portrait")
plan, _summary, job = await self.run_apply(OPERATION)
self.assertEqual(plan["backup"]["policy"], "off")
self.assertEqual(job.state, "finished", job.log)
self.assertIn("No safety backup (the policy is off)", "\n".join(job.log))
self.assertTrue(job.status_line.endswith("· No safety backup"))
self.assertEqual(self.backups(), [])
self.assertEqual(self.category(prompt), "portrait")
async def test_reviewed_with_the_policy_on_keeps_the_backup(self):
self.pair("promise", category="portrait")
plan = await self.preview(OPERATION)
PromptManagerConfig.SAFETY_BACKUP = False
body = await self.apply(plan["token"])
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(len(self.backups()), 1)
async def test_plan_changed_writes_nothing(self):
pair_id, prompt = self.pair("drift", category="portrait")
plan = await self.preview(OPERATION)
self.api.db.update_prompt_pair_metadata(pair_id, category="landscape")
body = await self.apply(plan["token"])
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(job.state, "failed")
self.assertEqual(job.last_event["code"], "plan-changed")
self.assertIsNone(self.category(prompt))
async def test_report_lists_every_filled_prompt(self):
PromptManagerConfig.SAFETY_BACKUP = False
count = MAX_OUTCOME_ROWS + 5
for n in range(count):
self.pair(f"bulk {n:03d}", category="portrait")
_plan, _summary, job = await self.run_apply(OPERATION)
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(len(job.outcomes["changed"]), MAX_OUTCOME_ROWS)
response = await self.client.request(
"GET", f"/prompt_manager/jobs/{job.id}/report"
)
self.assertEqual(response.status, 200)
report = await response.json()
changed = report["outcomes"]["changed"]
self.assertEqual(changed["count"], count)
self.assertEqual(len(changed["rows"]), count)
self.assertFalse(changed["truncated"])
self.assertTrue(
all(
row["detail"].endswith("category “portrait”") for row in changed["rows"]
)
)