* feat(system): read-only health check and truthful overview counts POST /prompt_manager/system/health runs the health check off-loop as a read registry job of kind "health" (one at a time, beside a scan, not cancellable, no file outcomes) and keeps the last report; GET returns it or null. The check reads one snapshot on its own mode=ro connection, closed in finally: integrity_check(100), foreign_key_check, stored hashes against utils.hashing, duplicate pair rows, the legacy consistency queries (now shared with check_consistency), and image links whose file is gone, counted only under readable sources. An unavailable source, or the absent Needs-review queue, makes its check unavailable and the report incomplete instead of producing a count. GET /prompt_manager/system/overview returns Library-eligible positive prompts and active negative variants, indexed images, the last health summary or null, backup null, the Needs-review count null, the effective sources with whether they were auto-detected, and the thumbnail plan's missing count. GET /prompt_manager/system/statistics lists runs and average rating per category of Library parents, with the /stats totals. * feat(system): native System route, drafts and shared review dialog - #/system/<section> renders the concept's System shell: section nav, settings layout and Save row with truthful status (D92); each section not delivered yet hands off to its legacy tools (D94). Overview's System card opens #/system/overview (Q12). - A module-level draft store saves browser, then host (expected_revision), then runtime; failed domains stay dirty, a stale revision is a conflict. - System row builders (setting, operation, section nav) on the scope. - openReviewDialog and backupNotice extracted from the scan review, which is now a thin caller with identical markup. - Overview images gain a "rated" count (images whose linked prompt is rated); the missing-files health check reports per source, so partial availability is visible. * feat(system): native Overview with health report and statistics System > Overview renders the concept's metrics and its Library health and Workspace preferences surfaces from GET /system/overview: real counts, "Not checked" until a health check ran, no invented review count, backup or thumbnail status (D93). Explore opens "Usage statistics" with the real runs and ratings per category and the Library totals (D96); the statistics route now also returns rated_images. Maintenance shows the concept's notice with Run health checks above its hand-off: the read-only check runs as a registry read job and opens the "Health report" with a chip per real result, missing files per source (D95). A load failure shows Retry and no zeros; late answers never repaint a newer section. Adds the System concept comparison (shell, Overview, both dialogs, the notice, mutation checks), the System workflow spec and its fixture hooks, and the TESTING.md "Native System" section. * test(system): coverage floors for the native Overview Adds the system-overview.mjs baseline and raises the system.mjs floors to their measured coverage; no other floor changes. * fix(system): keep health reports per database and off the event loop - A restore that replaces the database forgets the stored health report, and a check that started before the swap is not kept (database generation stamped at start). - The Overview no longer walks every source for the missing-thumbnail count; the page reads /images/thumbnails/status on its own. - Image sources for the health check and the Overview resolve in the executor; a failure to start the check answers the coded envelope. - Any exception fails only its own check; missing files reads its rows in the snapshot and stats the files after it ended. - The read-only URI keeps Windows UNC paths (empty authority). - An integrity count at SQLite's limit is flagged as capped. * fix(system): thumbnail row read, tracked health job and guarded host save - The Overview's thumbnail row is its own read of the thumbnail status: "Checking thumbnails…" until it answers, unavailable when it fails. - The health job is tracked in the Activity store (new store.track) and followed through its event stream instead of polling every 150 ms. - A host save before a read returned a revision fails without a request; System reads the host values on mount and after a conflict. - "100+ problems found" when the integrity count is capped. - D-NEW-S2-5 is D123 everywhere; the unused Overview hand-off is gone. - The concept spec builds the health report from the concept's own rows and asserts the full unmapped path list; surface tops pin the 18px offset. - TESTING.md: D96's reason, the read-only (WAL/SHM) standard, the UNC URI and the new behavior. Coverage floors raised for the touched files. * fix(system): make two health tests portable to Windows - Windows forbids "?" in file names, so the URI-character open test uses "#", "%" and a space there. - ntpath.realpath on Python 3.11+ returns a NUL-byte path instead of raising, so the bad-path test raises as POSIX does to reach the missing-files failure path on every platform.
615 lines
25 KiB
Python
615 lines
25 KiB
Python
"""The read-only health check (System › Overview and Maintenance).
|
||
|
||
``run_health_checks`` reads one snapshot on its own ``mode=ro`` connection:
|
||
SQLite's integrity and foreign-key checks, stored prompt hashes against
|
||
``utils.hashing``, duplicate pair rows, the legacy consistency queries, image
|
||
links whose file is gone (only under readable sources) and the Needs-review
|
||
count when one is supplied. It never writes, and it never turns a check it
|
||
could not run into a count.
|
||
"""
|
||
|
||
import hashlib
|
||
import ntpath
|
||
import os
|
||
import sqlite3
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
from database import health # noqa: E402
|
||
from database.operations import PromptDatabase # noqa: E402
|
||
from utils.hashing import generate_prompt_hash # noqa: E402
|
||
|
||
try:
|
||
from tests.open_handles import assert_closed # noqa: E402
|
||
except ImportError: # discovered with tests/ as the top-level directory
|
||
from open_handles import assert_closed # noqa: E402
|
||
|
||
CHECK_IDS = [
|
||
"integrity",
|
||
"foreign_keys",
|
||
"canonical_hashes",
|
||
"pair_uniqueness",
|
||
"missing_files",
|
||
"ambiguous_metadata",
|
||
"consistency",
|
||
]
|
||
|
||
|
||
def _digest(path):
|
||
if not os.path.exists(path):
|
||
return None
|
||
with open(path, "rb") as handle:
|
||
return hashlib.sha256(handle.read()).hexdigest()
|
||
|
||
|
||
class HealthTestCase(unittest.TestCase):
|
||
def setUp(self):
|
||
self.temp = tempfile.TemporaryDirectory()
|
||
self.root = Path(os.path.realpath(self.temp.name))
|
||
self.output = self.root / "output"
|
||
self.output.mkdir()
|
||
self.db_path = str(self.root / "prompts.db")
|
||
self.db = PromptDatabase(self.db_path)
|
||
|
||
def tearDown(self):
|
||
self.db.close_all()
|
||
assert_closed(self, self.root)
|
||
self.temp.cleanup()
|
||
|
||
def save(self, text, **metadata):
|
||
return self.db.save_prompt(
|
||
text, prompt_hash=generate_prompt_hash(text), **metadata
|
||
)
|
||
|
||
def image(self, prompt_id, name, *, exists=True, folder=None):
|
||
folder = Path(folder or self.output)
|
||
path = folder / name
|
||
if exists:
|
||
path.write_bytes(b"png")
|
||
return self.db.link_image_to_prompt(prompt_id, str(path))
|
||
|
||
def run_checks(self, roots=None, **kwargs):
|
||
roots = [str(self.output)] if roots is None else roots
|
||
return self.db.run_health_checks(roots, **kwargs)
|
||
|
||
@staticmethod
|
||
def check(report, check_id):
|
||
return next(c for c in report["checks"] if c["id"] == check_id)
|
||
|
||
def raw(self):
|
||
"""A plain connection for planting damage the app itself never writes."""
|
||
conn = sqlite3.connect(self.db_path)
|
||
conn.execute("PRAGMA foreign_keys=OFF")
|
||
return conn
|
||
|
||
|
||
class TestHealthReport(HealthTestCase):
|
||
def test_clean_library_passes_except_unavailable_review(self):
|
||
prompt = self.save("A clean prompt")
|
||
self.db.save_prompt_pair("Cat", "Blur")
|
||
self.image(prompt, "a.png")
|
||
report = self.run_checks(review_count=0)
|
||
self.assertTrue(report["success"])
|
||
self.assertEqual([c["id"] for c in report["checks"]], CHECK_IDS)
|
||
self.assertEqual(
|
||
[c["label"] for c in report["checks"]],
|
||
[
|
||
"Integrity check",
|
||
"Foreign keys & pair references",
|
||
"Canonical hashes",
|
||
"Pair uniqueness",
|
||
"Missing files",
|
||
"Ambiguous metadata",
|
||
"Consistency",
|
||
],
|
||
)
|
||
for check in report["checks"]:
|
||
self.assertEqual(check["status"], "passed", check)
|
||
self.assertEqual(check["count"], 0, check)
|
||
self.assertEqual(report["status"], "passed")
|
||
self.assertRegex(report["checked_at"], r"^\d{4}-\d\d-\d\dT.*\+00:00$")
|
||
|
||
def test_health_check_never_writes(self):
|
||
prompt = self.save("Untouched")
|
||
self.image(prompt, "kept.png")
|
||
self.image(prompt, "gone.png", exists=False)
|
||
with self.raw() as conn:
|
||
conn.execute("UPDATE prompts SET hash='stale' WHERE id=?", (prompt,))
|
||
conn.close()
|
||
# The app's own connection stays open, as in the running server.
|
||
self.db.get_statistics()
|
||
files = [self.db_path, self.db_path + "-wal"]
|
||
before = [_digest(path) for path in files]
|
||
shm_size = os.path.getsize(self.db_path + "-shm")
|
||
media = sorted(os.listdir(self.output))
|
||
opened = []
|
||
real_connect = sqlite3.connect
|
||
|
||
class Recording(sqlite3.Connection):
|
||
changes_at_close = None
|
||
|
||
def close(self):
|
||
Recording.changes_at_close = self.total_changes
|
||
super().close()
|
||
|
||
def connect(*args, **kwargs):
|
||
conn = real_connect(*args, factory=Recording, **kwargs)
|
||
opened.append((args, kwargs, conn))
|
||
return conn
|
||
|
||
with patch.object(health.sqlite3, "connect", side_effect=connect):
|
||
report = self.run_checks()
|
||
self.assertEqual(report["status"], "incomplete")
|
||
self.assertEqual(len(opened), 1)
|
||
args, kwargs, conn = opened[0]
|
||
self.assertTrue(str(args[0]).endswith("?mode=ro"), args)
|
||
self.assertTrue(kwargs.get("uri"))
|
||
self.assertEqual(Recording.changes_at_close, 0)
|
||
with self.assertRaises(sqlite3.ProgrammingError): # closed in finally
|
||
conn.execute("SELECT 1")
|
||
self.assertEqual([_digest(path) for path in files], before)
|
||
# The -shm holds WAL read marks every reader updates; it never grows.
|
||
self.assertEqual(os.path.getsize(self.db_path + "-shm"), shm_size)
|
||
self.assertEqual(sorted(os.listdir(self.output)), media)
|
||
with self.db.model.get_connection() as live:
|
||
stored = live.execute(
|
||
"SELECT hash FROM prompts WHERE id=?", (prompt,)
|
||
).fetchone()[0]
|
||
self.assertEqual(stored, "stale")
|
||
|
||
def test_connection_closed_when_a_check_raises(self):
|
||
self.save("Any")
|
||
with (
|
||
patch.object(
|
||
health,
|
||
"_canonical_hashes",
|
||
side_effect=sqlite3.OperationalError("boom"),
|
||
),
|
||
self.assertLogs("prompt_manager.database.health", "ERROR"),
|
||
):
|
||
report = self.run_checks(review_count=0)
|
||
canonical = self.check(report, "canonical_hashes")
|
||
self.assertEqual(canonical["status"], "failed")
|
||
self.assertIsNone(canonical["count"])
|
||
self.assertEqual(canonical["detail"], ["The check could not finish."])
|
||
self.assertEqual(self.check(report, "integrity")["status"], "passed")
|
||
self.assertEqual(report["status"], "incomplete")
|
||
self.db.close_all()
|
||
assert_closed(self, self.root)
|
||
|
||
def test_unopenable_database_fails_every_database_check(self):
|
||
missing = PromptDatabase.__new__(PromptDatabase)
|
||
missing.model = type("M", (), {"db_path": str(self.root / "nope.db")})()
|
||
with self.assertLogs("prompt_manager.database.health", "ERROR"):
|
||
report = missing.run_health_checks([str(self.output)], review_count=0)
|
||
statuses = {c["id"]: c["status"] for c in report["checks"]}
|
||
self.assertEqual(statuses["integrity"], "failed")
|
||
self.assertEqual(statuses["missing_files"], "failed")
|
||
self.assertEqual(statuses["ambiguous_metadata"], "passed")
|
||
self.assertEqual(report["status"], "incomplete")
|
||
self.assertFalse(os.path.exists(self.root / "nope.db"))
|
||
|
||
def test_integrity_and_foreign_key_issues_reported(self):
|
||
prompt = self.save("Linked")
|
||
with self.raw() as conn:
|
||
conn.execute("CREATE TABLE probe(a)")
|
||
conn.execute("CREATE INDEX probe_a ON probe(a)")
|
||
conn.executemany("INSERT INTO probe VALUES(?)", [(i,) for i in range(16)])
|
||
conn.execute(
|
||
"INSERT INTO generated_images(prompt_id,image_path,filename,file_path)"
|
||
" VALUES(?,?,?,?)",
|
||
(9999, str(self.output / "orphan.png"), "orphan.png", "orphan"),
|
||
)
|
||
conn.execute(
|
||
"INSERT INTO prompt_tags(prompt_id,tag_id) VALUES(?,?)", (prompt, 777)
|
||
)
|
||
conn.close()
|
||
with self.raw() as conn:
|
||
conn.execute("PRAGMA writable_schema=ON")
|
||
conn.execute(
|
||
"UPDATE sqlite_master SET sql='CREATE INDEX probe_a ON probe(a DESC)'"
|
||
" WHERE name='probe_a'"
|
||
)
|
||
conn.close()
|
||
self.db.close_all() # the next read sees the edited schema
|
||
(self.output / "orphan.png").write_bytes(b"png")
|
||
with self.raw() as conn:
|
||
expected = [r[0] for r in conn.execute("PRAGMA integrity_check(100)")]
|
||
conn.close()
|
||
self.assertGreater(len(expected), 10)
|
||
report = self.run_checks(review_count=0)
|
||
integrity = self.check(report, "integrity")
|
||
self.assertEqual(integrity["status"], "issues")
|
||
self.assertEqual(integrity["count"], len(expected))
|
||
self.assertEqual(integrity["detail"], expected[:10])
|
||
self.assertIn("missing from index probe_a", integrity["detail"][0])
|
||
foreign = self.check(report, "foreign_keys")
|
||
self.assertEqual(foreign["status"], "issues")
|
||
self.assertEqual(foreign["count"], 2)
|
||
self.assertEqual(
|
||
sorted(foreign["detail"]),
|
||
[
|
||
"generated_images row 1 references a missing prompts row",
|
||
"prompt_tags row 1 references a missing tags row",
|
||
],
|
||
)
|
||
consistency = self.check(report, "consistency")
|
||
self.assertEqual(consistency["status"], "issues")
|
||
self.assertEqual(consistency["count"], 1)
|
||
self.assertEqual(
|
||
consistency["detail"], ["Image 1 references non-existent prompt 9999"]
|
||
)
|
||
self.assertEqual(report["status"], "issues")
|
||
|
||
def test_canonical_hash_mismatch_counts_rows(self):
|
||
ids = [self.save(f"Prompt {n}") for n in range(14)]
|
||
with self.raw() as conn:
|
||
for n, prompt_id in enumerate(ids[:12]):
|
||
conn.execute(
|
||
"UPDATE prompts SET hash=? WHERE id=?", (f"bad{n}", prompt_id)
|
||
)
|
||
conn.execute("UPDATE prompts SET hash=NULL WHERE id=?", (ids[12],))
|
||
conn.close()
|
||
# Normalised spellings hash alike: this one is not a mismatch.
|
||
self.save(" MIXED Case ")
|
||
report = self.run_checks(review_count=0)
|
||
canonical = self.check(report, "canonical_hashes")
|
||
self.assertEqual(canonical["status"], "issues")
|
||
self.assertEqual(canonical["count"], 13)
|
||
self.assertEqual(len(canonical["detail"]), 10)
|
||
self.assertEqual(
|
||
canonical["detail"][0],
|
||
f"Prompt {ids[0]}: the stored hash does not match its text",
|
||
)
|
||
|
||
def test_duplicate_pair_rows_reported(self):
|
||
pair = self.db.save_prompt_pair("Cat", "")
|
||
positive = self.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||
self.db.save_prompt_pair("Dog", "Blur")
|
||
with self.raw() as conn:
|
||
# Only the partial index keeps positive-only pairs unique; a restored
|
||
# or hand-edited database may have lost it.
|
||
conn.execute("DROP INDEX idx_prompt_pairs_positive_only")
|
||
conn.executemany(
|
||
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id)"
|
||
" VALUES(?,NULL)",
|
||
[(positive,), (positive,)],
|
||
)
|
||
conn.close()
|
||
report = self.run_checks(review_count=0)
|
||
pairs = self.check(report, "pair_uniqueness")
|
||
self.assertEqual(pairs["status"], "issues")
|
||
self.assertEqual(pairs["count"], 2)
|
||
self.assertEqual(
|
||
pairs["detail"],
|
||
[f"Positive prompt {positive} with no negative: 3 pair rows"],
|
||
)
|
||
|
||
def test_integrity_count_at_the_limit_is_flagged_capped(self):
|
||
class Rows:
|
||
def __init__(self, rows):
|
||
self.rows = rows
|
||
|
||
def execute(self, _sql):
|
||
return iter([(row,) for row in self.rows])
|
||
|
||
capped = health._integrity(Rows(["bad"] * health.INTEGRITY_LIMIT), [])
|
||
self.assertEqual(capped["status"], "issues")
|
||
self.assertEqual(capped["count"], health.INTEGRITY_LIMIT)
|
||
self.assertTrue(capped["capped"])
|
||
exact = health._integrity(Rows(["bad"] * 3), [])
|
||
self.assertEqual(exact["count"], 3)
|
||
self.assertFalse(exact["capped"])
|
||
self.assertFalse(health._integrity(Rows(["ok"]), [])["capped"])
|
||
|
||
def test_an_unexpected_error_fails_only_its_own_check(self):
|
||
self.save("Any")
|
||
with (
|
||
patch.object(health, "_pair_uniqueness", side_effect=KeyError("boom")),
|
||
self.assertLogs("prompt_manager.database.health", "ERROR"),
|
||
):
|
||
report = self.run_checks(review_count=0)
|
||
statuses = {c["id"]: c["status"] for c in report["checks"]}
|
||
self.assertEqual(statuses.pop("pair_uniqueness"), "failed")
|
||
self.assertEqual(set(statuses.values()), {"passed"})
|
||
|
||
|
||
class TestDatabaseUri(unittest.TestCase):
|
||
"""The ``mode=ro`` URI of the database file, for every path form SQLite opens."""
|
||
|
||
def test_posix_path_is_a_local_file_uri(self):
|
||
self.assertEqual(
|
||
health.readonly_uri("/data/my db#1?.db", windows=False),
|
||
"file:///data/my%20db%231%3F.db?mode=ro",
|
||
)
|
||
|
||
def test_windows_drive_path(self):
|
||
self.assertEqual(
|
||
health.readonly_uri(r"C:\Users\Me\prompts.db", windows=True),
|
||
"file:///C:/Users/Me/prompts.db?mode=ro",
|
||
)
|
||
|
||
def test_windows_unc_path_has_an_empty_authority(self):
|
||
# pathlib spells \\server\share as file://server/share, which SQLite
|
||
# rejects (a non-local authority); an empty one keeps the UNC path.
|
||
uri = health.readonly_uri(r"\\server\share\Comfy UI\prompts.db", windows=True)
|
||
self.assertEqual(uri, "file:////server/share/Comfy%20UI/prompts.db?mode=ro")
|
||
self.assertTrue(ntpath.isabs(r"\\server\share\Comfy UI\prompts.db"))
|
||
|
||
def test_uri_opens_a_path_with_uri_characters(self):
|
||
with tempfile.TemporaryDirectory() as temp:
|
||
# "?" is not a valid Windows file name character; "#", "%" and spaces are.
|
||
name = "a #b%20 c.db" if os.name == "nt" else "a #b?c%20.db"
|
||
path = os.path.join(os.path.realpath(temp), name)
|
||
conn = sqlite3.connect(path)
|
||
try:
|
||
conn.execute("CREATE TABLE t(a)")
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
ro = sqlite3.connect(health.readonly_uri(path), uri=True)
|
||
try:
|
||
self.assertEqual(ro.execute("SELECT COUNT(*) FROM t").fetchone(), (0,))
|
||
with self.assertRaises(sqlite3.OperationalError):
|
||
ro.execute("INSERT INTO t VALUES(1)")
|
||
finally:
|
||
ro.close()
|
||
|
||
@unittest.skipUnless(os.name == "nt", "a native UNC path exists on Windows only")
|
||
def test_native_unc_path_opens(self):
|
||
with tempfile.TemporaryDirectory() as temp:
|
||
path = os.path.join(os.path.realpath(temp), "prompts.db")
|
||
conn = sqlite3.connect(path)
|
||
try:
|
||
conn.execute("CREATE TABLE t(a)")
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
drive, rest = ntpath.splitdrive(path)
|
||
unc = "\\\\localhost\\" + drive[0] + "$" + rest
|
||
if not os.path.exists(unc):
|
||
self.skipTest("the administrative share is not reachable here")
|
||
ro = sqlite3.connect(health.readonly_uri(unc), uri=True)
|
||
try:
|
||
self.assertEqual(ro.execute("SELECT COUNT(*) FROM t").fetchone(), (0,))
|
||
finally:
|
||
ro.close()
|
||
|
||
|
||
class TestMissingFiles(HealthTestCase):
|
||
def test_missing_files_counted_under_readable_sources_only(self):
|
||
prompt = self.save("Images")
|
||
self.image(prompt, "here.png")
|
||
for n in range(12):
|
||
self.image(prompt, f"gone-{n}.png", exists=False)
|
||
elsewhere = self.root / "elsewhere"
|
||
elsewhere.mkdir()
|
||
self.image(prompt, "outside.png", exists=False, folder=elsewhere)
|
||
report = self.run_checks(review_count=0)
|
||
missing = self.check(report, "missing_files")
|
||
self.assertEqual(missing["status"], "issues")
|
||
self.assertEqual(missing["count"], 12)
|
||
self.assertEqual(len(missing["detail"]), 10)
|
||
self.assertRegex(missing["detail"][0], r"^Image \d+: gone-0\.png is missing$")
|
||
self.assertNotIn(str(self.root), " ".join(missing["detail"]))
|
||
with self.db.model.get_connection() as conn:
|
||
self.assertEqual(
|
||
conn.execute("SELECT COUNT(*) FROM generated_images").fetchone()[0],
|
||
14,
|
||
)
|
||
|
||
def test_links_outside_every_source_are_named_not_counted(self):
|
||
prompt = self.save("Images")
|
||
elsewhere = self.root / "elsewhere"
|
||
elsewhere.mkdir()
|
||
self.image(prompt, "outside.png", exists=False, folder=elsewhere)
|
||
missing = self.check(self.run_checks(review_count=0), "missing_files")
|
||
self.assertEqual(missing["status"], "passed")
|
||
self.assertEqual(missing["count"], 0)
|
||
self.assertEqual(
|
||
missing["detail"],
|
||
["1 link outside the image sources was not checked."],
|
||
)
|
||
|
||
def test_missing_file_vs_unavailable_root(self):
|
||
# Per source (S2 ruling): the readable source is counted, the absent
|
||
# one reports "unavailable" with no count, and the report is incomplete.
|
||
prompt = self.save("Images")
|
||
absent = self.root / "unmounted"
|
||
absent.mkdir()
|
||
self.image(prompt, "on-nas.png", folder=absent)
|
||
self.image(prompt, "gone.png", exists=False)
|
||
os.unlink(absent / "on-nas.png")
|
||
absent.rmdir()
|
||
report = self.run_checks([str(self.output), str(absent)], review_count=0)
|
||
missing = self.check(report, "missing_files")
|
||
self.assertEqual(missing["status"], "issues")
|
||
self.assertEqual(missing["count"], 1)
|
||
self.assertTrue(missing["partial"])
|
||
self.assertEqual(
|
||
missing["sources"],
|
||
[
|
||
{"name": "output", "status": "checked", "count": 1},
|
||
{"name": "unmounted", "status": "unavailable", "count": None},
|
||
],
|
||
)
|
||
self.assertEqual(
|
||
missing["detail"][0],
|
||
"1 image source is unavailable, so its files were not counted.",
|
||
)
|
||
self.assertRegex(missing["detail"][1], r"^Image \d+: gone\.png is missing$")
|
||
# The link under the unavailable source is neither counted nor "outside".
|
||
self.assertEqual(len(missing["detail"]), 2)
|
||
self.assertNotIn(str(self.root), " ".join(missing["detail"]))
|
||
self.assertEqual(report["status"], "incomplete")
|
||
|
||
def test_partial_sources_with_no_missing_file_are_not_passed(self):
|
||
prompt = self.save("Images")
|
||
self.image(prompt, "here.png")
|
||
absent = self.root / "unmounted"
|
||
missing = self.check(
|
||
self.run_checks([str(self.output), str(absent)], review_count=0),
|
||
"missing_files",
|
||
)
|
||
self.assertEqual(missing["status"], "unavailable")
|
||
self.assertEqual(missing["count"], 0)
|
||
self.assertTrue(missing["partial"])
|
||
self.assertEqual(
|
||
[s["status"] for s in missing["sources"]], ["checked", "unavailable"]
|
||
)
|
||
|
||
def test_every_source_unavailable_counts_nothing(self):
|
||
prompt = self.save("Images")
|
||
absent = self.root / "unmounted"
|
||
self.image(prompt, "gone.png", exists=False, folder=self.output)
|
||
report = self.run_checks([str(absent)], review_count=0)
|
||
missing = self.check(report, "missing_files")
|
||
self.assertEqual(missing["status"], "unavailable")
|
||
self.assertIsNone(missing["count"])
|
||
self.assertFalse(missing["partial"])
|
||
self.assertEqual(
|
||
missing["sources"],
|
||
[{"name": "unmounted", "status": "unavailable", "count": None}],
|
||
)
|
||
self.assertEqual(
|
||
missing["detail"],
|
||
["1 image source is unavailable, so missing files were not counted."],
|
||
)
|
||
|
||
def test_each_source_counts_its_own_missing_files(self):
|
||
prompt = self.save("Images")
|
||
archive = self.root / "archive"
|
||
archive.mkdir()
|
||
self.image(prompt, "a.png", exists=False)
|
||
self.image(prompt, "b.png", exists=False, folder=archive)
|
||
self.image(prompt, "c.png", exists=False, folder=archive)
|
||
missing = self.check(
|
||
self.run_checks([str(self.output), str(archive)], review_count=0),
|
||
"missing_files",
|
||
)
|
||
self.assertEqual(missing["count"], 3)
|
||
self.assertFalse(missing["partial"])
|
||
self.assertEqual(
|
||
[(s["name"], s["count"]) for s in missing["sources"]],
|
||
[("output", 1), ("archive", 2)],
|
||
)
|
||
|
||
def test_no_sources_is_unavailable(self):
|
||
self.save("Images")
|
||
missing = self.check(self.run_checks([], review_count=0), "missing_files")
|
||
self.assertEqual(missing["status"], "unavailable")
|
||
self.assertIsNone(missing["count"])
|
||
self.assertEqual(missing["detail"], ["No image source is available."])
|
||
self.assertEqual(missing["sources"], [])
|
||
self.assertFalse(missing["partial"])
|
||
|
||
def test_a_bad_stored_path_fails_only_missing_files(self):
|
||
prompt = self.save("Images")
|
||
self.image(prompt, "here.png")
|
||
with self.raw() as conn:
|
||
conn.execute(
|
||
"INSERT INTO generated_images(prompt_id,image_path,filename)"
|
||
" VALUES(?,?,?)",
|
||
(prompt, str(self.root / "else\x00where.png"), "bad.png"),
|
||
)
|
||
conn.close()
|
||
real_realpath = os.path.realpath
|
||
|
||
def realpath(path, *args, **kwargs):
|
||
# POSIX (and Windows before Python 3.11) raise for the NUL byte;
|
||
# newer ntpath returns it unchanged. Raise everywhere, as POSIX does.
|
||
if "\x00" in str(path):
|
||
raise ValueError("embedded null byte")
|
||
return real_realpath(path, *args, **kwargs)
|
||
|
||
with (
|
||
patch.object(health.os.path, "realpath", side_effect=realpath),
|
||
self.assertLogs("prompt_manager.database.health", "ERROR"),
|
||
):
|
||
report = self.run_checks(review_count=0)
|
||
statuses = {c["id"]: c["status"] for c in report["checks"]}
|
||
self.assertEqual(statuses.pop("missing_files"), "failed")
|
||
self.assertEqual(set(statuses.values()), {"passed"})
|
||
|
||
def test_files_are_checked_after_the_read_snapshot_ends(self):
|
||
prompt = self.save("Images")
|
||
self.image(prompt, "here.png")
|
||
self.image(prompt, "gone.png", exists=False)
|
||
opened = []
|
||
real_connect = sqlite3.connect
|
||
|
||
def connect(*args, **kwargs):
|
||
conn = real_connect(*args, **kwargs)
|
||
opened.append(conn)
|
||
return conn
|
||
|
||
real_exists = os.path.exists
|
||
in_snapshot = []
|
||
|
||
def exists(path):
|
||
for conn in opened:
|
||
try:
|
||
in_snapshot.append(conn.in_transaction)
|
||
except sqlite3.ProgrammingError: # closed: no snapshot
|
||
in_snapshot.append(False)
|
||
return real_exists(path)
|
||
|
||
with (
|
||
patch.object(health.sqlite3, "connect", side_effect=connect),
|
||
patch.object(health.os.path, "exists", side_effect=exists),
|
||
):
|
||
missing = self.check(self.run_checks(review_count=0), "missing_files")
|
||
self.assertEqual((missing["status"], missing["count"]), ("issues", 1))
|
||
self.assertEqual(len(opened), 1)
|
||
self.assertTrue(in_snapshot)
|
||
self.assertNotIn(True, in_snapshot)
|
||
|
||
|
||
class TestAmbiguousMetadata(HealthTestCase):
|
||
def test_ambiguous_metadata_unavailable_without_review_queue(self):
|
||
self.save("Any")
|
||
report = self.run_checks()
|
||
ambiguous = self.check(report, "ambiguous_metadata")
|
||
self.assertEqual(ambiguous["status"], "unavailable")
|
||
self.assertIsNone(ambiguous["count"])
|
||
self.assertEqual(ambiguous["detail"], ["Needs review is not available yet."])
|
||
self.assertEqual(report["status"], "incomplete")
|
||
|
||
def test_review_count_reports_images_needing_review(self):
|
||
report = self.run_checks(review_count=3)
|
||
ambiguous = self.check(report, "ambiguous_metadata")
|
||
self.assertEqual(ambiguous["status"], "issues")
|
||
self.assertEqual(ambiguous["count"], 3)
|
||
self.assertEqual(ambiguous["detail"], [])
|
||
self.assertEqual(report["status"], "issues")
|
||
|
||
def test_invalid_review_count_is_rejected(self):
|
||
for value in (-1, True, "3", 1.5):
|
||
with self.assertRaises(ValueError):
|
||
self.run_checks(review_count=value)
|
||
|
||
|
||
class TestOverallStatus(unittest.TestCase):
|
||
def test_overall_status_precedence(self):
|
||
def checks(*statuses):
|
||
return [{"status": status} for status in statuses]
|
||
|
||
self.assertEqual(health.overall_status(checks("passed", "passed")), "passed")
|
||
self.assertEqual(health.overall_status(checks("passed", "issues")), "issues")
|
||
self.assertEqual(
|
||
health.overall_status(checks("issues", "unavailable")), "incomplete"
|
||
)
|
||
self.assertEqual(health.overall_status(checks("failed")), "incomplete")
|
||
# A check that found issues under only some sources is incomplete too.
|
||
partial = [{"status": "issues", "partial": True}, {"status": "passed"}]
|
||
self.assertEqual(health.overall_status(partial), "incomplete")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|