test(images): portable delete tests, the native no-trash proof, D159 in TESTING.md (#480)

This commit is contained in:
Vito Sansevero
2026-10-29 10:06:02 -07:00
parent 4e21e97e12
commit a5125fd42f
5 changed files with 102 additions and 8 deletions
+53
View File
@@ -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
+27 -2
View File
@@ -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(), [])
+10 -4
View File
@@ -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))
+6
View File
@@ -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
View File
@@ -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: