test(images): portable delete tests, the native no-trash proof, D159 in TESTING.md (#480)
This commit is contained in:
+53
@@ -2399,6 +2399,59 @@ npm run --prefix tools/vendor test:workflows -- workspace-collections.spec.mjs c
|
||||
images-concept.spec.mjs inspector-concept.spec.mjs shell-concept.spec.mjs --project chromium --project touch
|
||||
```
|
||||
|
||||
### Deleting images (#480)
|
||||
|
||||
Images › Delete… removes the checked images (or "Select all N matching", at most 500, or
|
||||
the one file of the side panel or the viewer) from the library, and on request moves the
|
||||
files to the system trash. **No code path unlinks or overwrites a media file.**
|
||||
|
||||
- **D159** (#480, maintainer request): the concept's "Remove links…" (`danger`, app.js
|
||||
432) returns as **Delete…** in the same place (after Export metadata, before Clear), and
|
||||
as the last action of the side panel and after Download in the Images viewer. Its
|
||||
review is the concept's `maintenancePlan` (`dl.meta-grid`, Cancel and a primary) with a
|
||||
radio group first, "What should happen to the files?", nothing checked (the ruling: ask
|
||||
every time): "Remove from the library only" or "Also move the files to the trash". The
|
||||
rows follow the chosen mode (Scope, Not changed, Prompt links, Image details, Backup,
|
||||
Media on disk) and the primary is `danger`: "Remove N images from the library" / "Move
|
||||
N images to the trash". `tests/browser/images-delete-concept.spec.mjs` compares the bar
|
||||
button and the review's grid and actions with the concept.
|
||||
- **Routes.** `POST /prompt_manager/images/delete/preview {items | filter}` reviews both
|
||||
modes at once under the maintenance preview lease and answers a reviewed plan
|
||||
(`_plan_response`, operation `delete_images`): `library.count`, `trash.count`, `refused`
|
||||
and `trash_refused` (`{count, counts, rows ≤ 50}` with coded reasons), `records`,
|
||||
`orphaned_prompts` and the backup. Each target is matched to its entry in the current
|
||||
listing (`not-listed` otherwise). `POST /prompt_manager/maintenance/apply {token, mode}`
|
||||
(`mode` required for this plan, refused for any other) starts job kind `image_delete`
|
||||
(admission `records`, not cancellable; groups `trashed`, `removed`, `not_changed`,
|
||||
`errors`): sources unchanged, the promised safety backup, then per file a recheck
|
||||
(containment and fingerprint) and
|
||||
- library mode: one transaction deletes the file's records and writes its removal mark;
|
||||
- trash mode: the file moves first (`utils/trash.py`); a refused move changes nothing;
|
||||
then its records go. A records failure stops the job (`records-failed`); later files
|
||||
are "Not attempted".
|
||||
- **What goes** (`database/image_removal.py`): image links under every spelling (pair
|
||||
links by cascade, workflow documents released), image tags, auto-tag marks, pending
|
||||
undecided Needs review items, the own category, collection memberships and the
|
||||
favorite. A decided or revised review item keeps its decision and the annotation rows it
|
||||
cites. Prompts, recorded runs and the vocabulary stay. Previews are removed.
|
||||
- **Removal marks** (`image_removals`, `image_removals_meta`): a library-only removal
|
||||
hides exactly that file (locator, size, `mtime_ns`) from the workspace listing, the
|
||||
output scan, the duplicate find, Tag output images and linking. A modified or replaced
|
||||
file shows again. An explicit tag, category, favorite or collection write clears the
|
||||
mark. Older builds ignore the tables (removed files show there); restoring an older
|
||||
backup shows every file again; restoring a pre-delete backup after trash mode brings
|
||||
back records of files that are in the trash (Missing-file links cleans them up). The
|
||||
legacy gallery lists every file on disk, removed or not. Hard links have one mark per
|
||||
path, and on FAT/exFAT/SMB a same-size replacement can stay hidden.
|
||||
- **Trash backends** (`utils/trash.py`, no dependency): Linux and BSD implement the
|
||||
freedesktop Trash specification (home trash on the same mount, else the mount's
|
||||
`.Trash/$uid` or `.Trash-$uid`), reserve the name with `O_EXCL` and move with
|
||||
`renameat2(RENAME_NOREPLACE)`; without it, across devices, on a read-only mount or with
|
||||
a trash inside an output root the move is refused (`no-trash`, `trash-in-root`). Windows
|
||||
and macOS report `no-trash` until their own backends land; library-only removal works
|
||||
everywhere. Tests use `tests/fake_trash.py`; the browser test server installs it, so no
|
||||
test touches a real trash.
|
||||
|
||||
### Prompt library cards and recorded deviations (#249 PR E)
|
||||
|
||||
The shelf uses the shared concept toolbar and caption (E1) and the concept's
|
||||
|
||||
@@ -8,6 +8,7 @@ first (trash mode, the fake trash here). No path unlinks a media file.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -257,8 +258,11 @@ class TestFileCheck(ImageDeleteTestCase):
|
||||
outside = Path(self.tmpdir) / "elsewhere"
|
||||
outside.mkdir()
|
||||
(outside / "x.png").write_bytes(b"x")
|
||||
os.symlink(outside / "x.png", self.output_dir / "link.png")
|
||||
os.symlink(outside, self.output_dir / "linked")
|
||||
try:
|
||||
os.symlink(outside / "x.png", self.output_dir / "link.png")
|
||||
os.symlink(outside, self.output_dir / "linked", target_is_directory=True)
|
||||
except OSError:
|
||||
self.skipTest("symlinks are not permitted here")
|
||||
self.assertEqual(
|
||||
file_check(str(self.output_dir / "link.png"), root, "link.png")[0], "link"
|
||||
)
|
||||
@@ -279,3 +283,24 @@ class TestFileCheck(ImageDeleteTestCase):
|
||||
|
||||
for code in trash.CODES:
|
||||
self.assertIn(code, image_delete.REASONS)
|
||||
|
||||
|
||||
class TestNativeBackend(ImageDeleteTestCase):
|
||||
"""The real backend of this OS: Windows and macOS have no trash yet (#480)."""
|
||||
|
||||
async def test_library_only_works_and_the_trash_is_unavailable_here(self):
|
||||
fake_trash.uninstall()
|
||||
self.write("a.png", b"aaa")
|
||||
self.link("a.png")
|
||||
plan = await self.review(["a.png"])
|
||||
native_trash = sys.platform not in ("win32", "darwin")
|
||||
if not native_trash:
|
||||
self.assertEqual(plan["trash"], {"count": 0})
|
||||
self.assertEqual(plan["trash_refused"]["counts"], {"no-trash": 1})
|
||||
body = await self.post(APPLY, {"token": plan["token"], "mode": "library"}, 202)
|
||||
job = self.api._jobs.get(body["job"]["id"])
|
||||
await asyncio.wait_for(asyncio.shield(job.task), DISK_BOUND_SECONDS)
|
||||
self.assertEqual(job.counters["removed"], 1, job.summary())
|
||||
self.assertTrue((self.output_dir / "a.png").exists())
|
||||
self.assertEqual(self.count("SELECT COUNT(*) FROM generated_images"), 0)
|
||||
self.assertEqual(await self.listed(), [])
|
||||
|
||||
@@ -33,8 +33,13 @@ class _RemovalCase(unittest.TestCase):
|
||||
base = Path(self.temp.name)
|
||||
self.root = base / "output"
|
||||
self.root.mkdir()
|
||||
# A second spelling of the folder (a symlink); Windows without the
|
||||
# privilege has none, and the records then carry one spelling.
|
||||
self.alias = base / "alias"
|
||||
self.alias.symlink_to(self.root, target_is_directory=True)
|
||||
try:
|
||||
self.alias.symlink_to(self.root, target_is_directory=True)
|
||||
except OSError:
|
||||
self.alias = None
|
||||
self.db = PromptDatabase(str(base / "prompts.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
self.epoch = self.db.get_execution_epoch()
|
||||
@@ -47,7 +52,8 @@ class _RemovalCase(unittest.TestCase):
|
||||
def removal(self, path):
|
||||
root_key = os.path.normcase(os.path.realpath(str(self.root)))
|
||||
return {
|
||||
"spellings": [str(path), str(self.alias / path.name)],
|
||||
"spellings": [str(path)]
|
||||
+ ([str(self.alias / path.name)] if self.alias else []),
|
||||
"locator": _locator(path),
|
||||
"favorites": [(root_key, path.name.lower())],
|
||||
"category": (root_key, path.name.lower()),
|
||||
@@ -113,8 +119,8 @@ class ImageRemovalRecordsTests(_RemovalCase):
|
||||
orphan, _ = self.link(path, "only this image")
|
||||
shared, _ = self.link(path, "two images")
|
||||
self.db.link_image_to_prompt(str(shared), str(other))
|
||||
# A record under the symlinked folder spelling.
|
||||
self.link(path, "aliased", spelled=self.alias / "a.png")
|
||||
# A record under the symlinked folder spelling (or a third prompt).
|
||||
self.link(path, "aliased", spelled=(self.alias or self.root) / "a.png")
|
||||
removal = self.decorate(path)
|
||||
self.assertTrue(self.queue(path))
|
||||
|
||||
|
||||
@@ -281,6 +281,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-delete.mjs": {
|
||||
"lines": 80,
|
||||
"branches": 70,
|
||||
"functions": 80,
|
||||
"statements": 80
|
||||
},
|
||||
"workspace/images-export.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
|
||||
+6
-2
@@ -115,10 +115,12 @@ def _ismount_walk(path: str) -> str:
|
||||
|
||||
|
||||
def _libc_renameat2():
|
||||
if os.name != "posix":
|
||||
return None # Windows: no libc, and no freedesktop backend
|
||||
try:
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
function = libc.renameat2
|
||||
except (OSError, AttributeError):
|
||||
except (OSError, AttributeError, TypeError):
|
||||
return None
|
||||
function.argtypes = [
|
||||
ctypes.c_int,
|
||||
@@ -191,7 +193,9 @@ class FreedesktopBackend:
|
||||
self._data_home = data_home
|
||||
self._mounts = mounts or _proc_mounts
|
||||
self._rename = rename or rename_noreplace
|
||||
self._uid = os.getuid() if uid is None else uid
|
||||
if uid is None:
|
||||
uid = os.getuid() if hasattr(os, "getuid") else -1
|
||||
self._uid = uid
|
||||
self._now = now or datetime.datetime.now
|
||||
|
||||
def _home_data(self) -> str:
|
||||
|
||||
Reference in New Issue
Block a user