fix(HEL-887): keep selection for kept files, unblock invalid-only delete, composited locate pulse

- library deleteMutation.onSuccess: only paths the server really deleted
  leave the selection (accumulated across 200-path chunks); kept files
  stay checked, current focus cleared only when its file is gone;
  in-modal freed-file delete and card-delete-with-file prune the same way
- usage modal: direct delete offered whenever valid_total === 0,
  regardless of invalid_total (dead references never block); invalid
  count note kept in both branches
- e2e runner: isolated_data_removed recorded after the
  TemporaryDirectory context exits and exact child PIDs are waited on
  (daemon PID compared first); isolated dir recorded for verification
- locate gold ring: static ring kept, pulse moved to a ::before feedback
  layer animated with transform/opacity only (no box-shadow repaint),
  gated to data-motion=full; calm/off/reduced-motion stay static
- e2e: +14 checks (46 total) — mixed kept/deleted selection, failed
  request retry, cross-200-batch with kept file, invalid-only in-modal
  delete, ring computed-style/keyframe evidence; HEL884_STRICT=0 records
  the full before-fix failure table

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
施工员2号
2026-10-27 01:02:12 +08:00
co-authored by multica-agent
parent 679969bc5b
commit e88dc0ae40
4 changed files with 236 additions and 28 deletions
+21 -8
View File
@@ -76,27 +76,40 @@
border-color: color-mix(in srgb, var(--scene) 45%, transparent);
}
/* HEL-884 locate deep-link: the looked-for card gets an unmistakable gold
ring + soft pulse until dismissed (20s) so it reads at any zoom. */
ring until dismissed (20s) so it reads at any zoom. HEL-887: the ring
itself is a static layer; the pulse breathes on a separate ::before
feedback layer animated with transform/opacity only — composited, no
per-frame box-shadow repaint. */
.canvas-card.located {
border-color: var(--gold);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--gold) 45%, transparent),
var(--sh-2);
}
html[data-motion="full"] .canvas-card.located {
.canvas-card.located::before {
content: "";
position: absolute;
inset: -3px;
border-radius: calc(var(--r-lg) + 3px);
border: 3px solid color-mix(in srgb, var(--gold) 45%, transparent);
opacity: 0;
pointer-events: none;
}
.canvas-card.artifact-node.located::before {
border-radius: calc(var(--r-md) + 3px);
}
html[data-motion="full"] .canvas-card.located::before {
animation: located-pulse 1.6s var(--ease-out) infinite;
}
@keyframes located-pulse {
0%,
100% {
box-shadow:
0 0 0 3px color-mix(in srgb, var(--gold) 45%, transparent),
var(--sh-2);
opacity: 0;
transform: scale(1);
}
50% {
box-shadow:
0 0 0 6px color-mix(in srgb, var(--gold) 22%, transparent),
var(--sh-2);
opacity: 1;
transform: scale(1.04);
}
}
.canvas-card:focus-visible {
+20 -9
View File
@@ -238,7 +238,12 @@
usageError = {};
usagePaths = onCanvas;
}
selected.clear();
// Only files that were really deleted leave the selection; kept ones
// stay checked so a mixed batch never wipes the user's selection
// (HEL-887). Batches are accumulated across the 200-path chunks.
const deletedPaths = new Set(batch.flatMap((r) => r.deleted));
for (const p of deletedPaths) selected.delete(p);
if (current && deletedPaths.has(current)) current = null;
deletePaths = [];
void client.invalidateQueries({ queryKey: ["library-media"] });
},
@@ -337,9 +342,13 @@
pendingAction = null;
if (kind === "release")
toast.success("已仅移除卡片,原文件保留在素材库");
else if (res.file_deleted)
else if (res.file_deleted) {
// The file left the library with its card — it must also leave the
// selection, exactly like a direct library delete (HEL-887).
selected.delete(path);
if (current === path) current = null;
toast.success("卡片已删除,文件已一并删除");
else toast.success("卡片已删除,文件保留");
} else toast.success("卡片已删除,文件保留");
void loadUsage(path);
void client.invalidateQueries({ queryKey: ["library-media"] });
} catch (e) {
@@ -355,6 +364,8 @@
const r = res.results[0];
if (r?.status === "deleted") {
toast.success("文件已删除");
selected.delete(path);
if (current === path) current = null;
usagePaths = usagePaths.filter((p) => p !== path);
} else {
toast.error(`未能删除 · ${KEPT_REASONS[r?.reason ?? "other"] ?? "无法删除"}`);
@@ -729,7 +740,7 @@
<p class="usage-empty usage-error">占用信息读取失败:{usageError[path]}</p>
<Button size="sm" onclick={() => loadUsage(path)}>重试</Button>
{:else if usage}
{#if usage.usages.length === 0 && usage.invalid_total === 0}
{#if usage.valid_total === 0}
<div class="usage-free">
<p>当前没有画布卡片占用此文件了,现在可以删除。</p>
<Button
@@ -787,11 +798,11 @@
>还有 {usage.valid_total - usage.usages.length} 处引用,展开下一批(每次 {usage.limit} 条)</button
>
{/if}
{#if usage.invalid_total > 0}
<p class="usage-invalid">
另有 {usage.invalid_total} 条已失效引用(所属画布已不存在),不占用此文件,也无需处理。
</p>
{/if}
{/if}
{#if usage.invalid_total > 0}
<p class="usage-invalid">
另有 {usage.invalid_total} 条已失效引用(所属画布已不存在),不占用此文件,也无需处理。
</p>
{/if}
{/if}
</section>
+150 -3
View File
@@ -11,10 +11,13 @@ const fs = require("node:fs"),
const OUT = "../evidence/hel884";
const WEB = "http://127.0.0.1:15274";
const report = { checks: [], errors: [] };
// HEL884_STRICT=0 records every failure instead of aborting at the first —
// used to capture the full before-fix failure table in one base-build run.
const STRICT = process.env.HEL884_STRICT !== "0";
const ok = (name, pass) => {
report.checks.push({ name, pass: !!pass });
assert(pass, name);
console.log("PASS", name);
console.log(pass ? "PASS" : "FAIL", name);
if (STRICT) assert(pass, name);
};
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
const lastToast = (p) =>
@@ -110,6 +113,56 @@ const usageModal = (p) => p.getByRole("dialog").filter({ hasText: "画布占用
(await p.getByTestId("located-card").textContent()).includes("主占用卡"));
ok("locate: viewport-out card brought into view", inView && inView.x > 0 &&
inView.y > 0 && inView.x + inView.w < inView.vw && inView.y + inView.h < inView.vh);
// HEL-887: the gold ring is a static layer; the pulse breathes on a
// ::before feedback layer animated with transform/opacity only, and only
// under data-motion=full — calm/off (and reduced-motion → off) stay static.
const ring = await p.evaluate(async () => {
const el = document.querySelector("[data-testid='located-card']");
if (!el) return null;
const raf = () => new Promise((r) => requestAnimationFrame(() => r()));
// Mirror exactly what reduceMotion.apply() writes per mode — data-reduce=1
// carries a global animation:none kill-switch, so motion alone is not the
// whole state.
const setMode = (mode) => {
const root = document.documentElement;
root.dataset.motion = mode;
root.dataset.reduce = mode === "off" ? "1" : "0";
root.classList.toggle("calm", mode === "calm");
root.classList.toggle("reduce", mode === "off");
};
const staticRing = getComputedStyle(el).boxShadow;
const layerName = () => getComputedStyle(el, "::before").animationName;
setMode("off");
const off = { name: layerName(), opacity: getComputedStyle(el, "::before").opacity };
setMode("calm");
const calm = layerName();
setMode("full");
await raf();
await raf();
const full = layerName();
// Element.getAnimations() skips pseudo targets in Chromium;
// document.getAnimations() carries them with effect.pseudoElement set.
const anims = document.getAnimations()
.filter((a) => a.effect?.target === el && a.effect?.pseudoElement === "::before")
.map((a) => ({
name: a.animationName,
props: [...new Set(a.effect.getKeyframes().flatMap((f) =>
Object.keys(f).filter((k) =>
!["offset", "computedOffset", "easing", "composite"].includes(k))))].sort(),
}));
setMode("off");
return { staticRing, off, calm, full, anims };
});
ok("ring: static gold ring stays on the located card",
!!ring && /0px 0px 0px 3px/.test(ring.staticRing));
ok("ring: pulse layer runs only under motion=full",
!!ring && ring.full === "located-pulse" &&
ring.calm === "none" &&
ring.off.name === "none" && ring.off.opacity === "0");
ok("ring: pulse keyframes animate transform/opacity only",
!!ring && ring.anims.length === 1 &&
ring.anims[0].name === "located-pulse" &&
JSON.stringify(ring.anims[0].props) === JSON.stringify(["opacity", "transform"]));
await p.screenshot({ path: `${OUT}/02-canvas-located.png` });
// ── 4. back chip returns to the library; selection round-trip ──────
@@ -214,10 +267,104 @@ const usageModal = (p) => p.getByRole("dialog").filter({ hasText: "画布占用
!(await p.getByText("主占用卡").count()));
await p.screenshot({ path: `${OUT}/08-canvas-after.png` });
// ── 11. HEL-887 mixed delete: kept stays checked, deleted leaves ────
const pickedPaths = () => p.evaluate(() =>
[...document.querySelectorAll(".lt.picked")].map((el) =>
el.querySelector(".bd b")?.textContent ?? ""));
await p.goto(WEB + "/library");
await p.getByRole("button", { name: /查看 running-held/ }).waitFor();
await p.getByRole("button", { name: "选择", exact: true }).click();
await p.getByRole("button", { name: /选择 running-held/ }).click();
await p.getByRole("button", { name: /选择 free-upload/ }).click();
await p.getByText("已选 2", { exact: true }).waitFor();
// transport failure: the whole selection must survive for a retry
await p.route("**/api/library/media", async (route) => {
if (route.request().method() === "DELETE") await route.abort("failed");
else await route.continue();
});
await p.getByRole("button", { name: "删除", exact: true }).click();
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
await pause(700);
ok("retry: failed delete keeps every checked path",
/删除请求失败/.test(await lastToast(p)) &&
(await p.getByText("已选 2", { exact: true }).count()) === 1);
// retry the same modal: deleted leaves the selection, kept stays checked
await p.unroute("**/api/library/media");
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
await pause(1400);
ok("mixed: only the deleted path leaves the selection",
/已删除 1 个文件/.test(await lastToast(p)) &&
(await p.getByText("已选 1").count()) === 1 &&
!fs.existsSync(byName["free-upload.png"]) &&
fs.existsSync(byName["running-held.png"]));
report.mixedSelection = await pickedPaths();
// kept selection survives a canvas round-trip and a full refresh
await p.goto(`${WEB}/canvas/${seeded.canvas_b}`);
await p.getByTestId("titlecard").waitFor();
await p.goBack();
await p.getByRole("button", { name: /查看 running-held/ }).waitFor();
ok("mixed: kept selection restored after canvas round-trip",
(await p.getByText("已选 1").count()) === 1);
await p.reload();
await p.getByRole("button", { name: /查看 running-held/ }).waitFor();
ok("mixed: kept selection restored after full refresh",
(await p.getByText("已选 1").count()) === 1 &&
!(await p.getByRole("button", { name: /查看 free-upload/ }).count()));
if (await p.getByRole("button", { name: "取消", exact: true }).count())
await p.getByRole("button", { name: "取消", exact: true }).click();
// ── 12. HEL-887 invalid-only references still allow in-modal delete ─
const modal6 = await openUsage("mixed-free.png");
await modal6.getByText("混合占用卡").waitFor();
ok("mixed-free: live card listed with the invalid-reference note",
(await modal6.getByText(/另有 1 条已失效引用/).count()) === 1);
await modal6.getByRole("button", { name: "仅移除卡片" }).click();
await modal6.getByRole("button", { name: "确认仅移除" }).click();
await pause(1400);
// last live card released → only a dead reference remains: the direct
// delete must still be offered (invalid_total must not block it)
const freed = await modal6.getByText("当前没有画布卡片占用此文件了,现在可以删除。")
.waitFor({ timeout: 4000 }).then(() => true).catch(() => false);
ok("mixed-free: direct delete offered with only invalid refs left",
freed &&
(await modal6.getByRole("button", { name: "删除此文件" }).count()) === 1 &&
(await modal6.getByText(/另有 1 条已失效引用/).count()) === 1);
await p.screenshot({ path: `${OUT}/09-invalid-only-direct.png` });
if ((await modal6.getByRole("button", { name: "删除此文件" }).count()))
await modal6.getByRole("button", { name: "删除此文件" }).click();
await pause(1300);
ok("mixed-free: file really deleted, invalid refs don't resurrect",
/文件已删除/.test(await lastToast(p)) &&
!fs.existsSync(byName["mixed-free.png"]));
await usageModal(p).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
// ── 13. HEL-887 cross-200-batch delete with a kept file in the batch ─
await p.goto(WEB + "/library");
await p.getByRole("button", { name: /查看 running-held/ }).waitFor();
const remaining = (await (await fetch("http://127.0.0.1:18274/api/library/media")).json()).length;
await p.getByRole("button", { name: "选择本组" }).click();
await p.getByText(`已选 ${remaining}`, { exact: true }).waitFor();
ok("batch: 206-path selection crosses the 200-path chunk boundary",
remaining === 206);
await p.getByRole("button", { name: "删除", exact: true }).click();
await p.getByRole("dialog").getByRole("button", { name: "确认删除" }).click();
await pause(2200);
ok("batch: 205 files deleted across both chunks",
/已删除 205 个文件/.test(await lastToast(p)) &&
!fs.existsSync(byName["sel-000.png"]) && !fs.existsSync(byName["sel-204.png"]));
ok("batch: kept file stays checked after the cross-batch delete",
(await p.getByText("已选 1").count()) === 1 &&
fs.existsSync(byName["running-held.png"]));
report.batchSelection = await pickedPaths();
ok("batch: library lists only the kept file afterwards",
(await (await fetch("http://127.0.0.1:18274/api/library/media")).json()).length === 1);
await p.screenshot({ path: `${OUT}/10-final-library.png` });
await ctx.close();
await browser.close();
fs.writeFileSync(`${OUT}/report.json`, JSON.stringify(report, null, 2));
console.log(`HEL-884: ${report.checks.length} checks passed`);
console.log(`HEL-884: ${report.checks.filter((c) => c.pass).length}/${report.checks.length} checks passed`);
if (report.checks.some((c) => !c.pass)) process.exit(1);
})().catch((e) => {
report.errors.push(String(e));
fs.writeFileSync(`${OUT}/report.json`, JSON.stringify(report, null, 2));
+45 -8
View File
@@ -2,11 +2,13 @@
Seeds a self-contained library (uploads + scratch outputs), two canvases
(sandbox session canvas + project canvas) sharing one file, an orphan
node whose canvas row is gone, a running-job card, and a release-only
file — then drives the real /library and /canvas pages via e2e/hel884.cjs.
node whose canvas row is gone, a running-job card, a release-only
file, a live+orphan mixed file, and a 205-file batch that crosses the
200-path delete chunk — then drives the real /library and /canvas pages
via e2e/hel884.cjs.
No real LLM/GPU/paid service is called; every byte of media is created here.
"""
import json, os, signal, socket, sqlite3, subprocess, sys, tempfile, time
import contextlib, json, os, signal, socket, sqlite3, subprocess, sys, tempfile, time
import urllib.error, urllib.parse, urllib.request
from pathlib import Path
@@ -41,8 +43,28 @@ PNG = bytes.fromhex(
) # canonical 1x1 RGBA PNG fixture
processes, handles, cleanup = [], [], []
with tempfile.TemporaryDirectory(prefix="hel884-", dir=out) as tmp:
tmp = Path(tmp)
def record_cleanup() -> None:
# HEL-887: recorded only AFTER the TemporaryDirectory context exited
# (the isolated dir is really gone) and after every exact child PID was
# waited on. The old in-finally write raced the context exit and always
# logged isolated_data_removed=false. The daemon PID is compared first;
# nothing is ever killed by process name.
isolated = state.get("tmp")
removed = bool(isolated) and not isolated.exists()
(out / f"{phase}-cleanup.json").write_text(json.dumps(
{"children": cleanup, "isolated_dir": str(isolated or ""),
"isolated_data_removed": removed}, indent=2))
print("CLEANUP", cleanup, "isolated data removed:", removed, flush=True)
state: dict = {}
with contextlib.ExitStack() as stack:
# LIFO: the TemporaryDirectory exits (and removes the dir) BEFORE the
# cleanup record callback runs, on success and on any exception.
stack.callback(record_cleanup)
tmp = Path(stack.enter_context(
tempfile.TemporaryDirectory(prefix="hel884-", dir=out)))
state["tmp"] = tmp
env = {**os.environ, "PYTHONPATH": str(root / "calliope-backend/src"),
"HEL884_DATA": str(tmp), "HEL884_BUILD": build_dir,
"LD_LIBRARY_PATH": BROWSER_LIBS,
@@ -142,6 +164,20 @@ http.createServer((req,res)=>{
db.execute("INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, status)"
" VALUES (?, 'image', '孤儿卡', 0, 0, ?, 'done')",
(ghost, str(up / "orphan-only.png")))
# HEL-887: mixed-free carries one LIVE card plus one orphan reference
# — releasing the live card leaves only invalid references behind,
# which must still offer the in-modal direct delete.
(up / "mixed-free.png").write_bytes(PNG)
db.execute("INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, status)"
" VALUES (?, 'image', '混合占用卡', 700, 700, ?, 'done')",
(canvas_a, str(up / "mixed-free.png")))
db.execute("INSERT INTO canvas_node (canvas_id, type, title, x, y, artifact_path, status)"
" VALUES (?, 'image', '混合孤儿卡', 0, 0, ?, 'done')",
(ghost, str(up / "mixed-free.png")))
# HEL-887: 205 directly-seeded files — one delete attempt over the
# 200-path chunk boundary with a kept file in the second chunk.
for i in range(205):
(up / f"sel-{i:03d}.png").write_bytes(PNG)
db.commit()
db.execute("PRAGMA foreign_keys = OFF")
db.execute("DELETE FROM canvas WHERE id = ?", (ghost,))
@@ -183,6 +219,9 @@ http.createServer((req,res)=>{
orphan_usage = json.load(urllib.request.urlopen(
base + f"/api/library/media/usage?path={urllib.parse.quote(path_of('orphan-only.png'))}",
timeout=10))
mixed_usage = json.load(urllib.request.urlopen(
base + f"/api/library/media/usage?path={urllib.parse.quote(path_of('mixed-free.png'))}",
timeout=10))
req = urllib.request.Request(
base + "/api/library/media", method="DELETE",
data=json.dumps({"paths": [path_of("orphan-only.png")]}).encode(),
@@ -201,6 +240,7 @@ http.createServer((req,res)=>{
"listed_names": [i["name"] for i in listed],
"usage_locate_main": usage,
"usage_orphan_only": orphan_usage,
"usage_mixed_free": mixed_usage,
"orphan_delete": orphan_delete,
"running_card_direct_delete": running_guard,
}
@@ -230,6 +270,3 @@ http.createServer((req,res)=>{
cleanup.append({"pid": p.pid, "exit": p.returncode, "daemon_pid": daemon})
for h in handles:
h.close()
(out / f"{phase}-cleanup.json").write_text(json.dumps(
{"children": cleanup, "isolated_data_removed": not Path(tmp).exists()}, indent=2))
print("CLEANUP", cleanup, "isolated data removed:", not Path(tmp).exists(), flush=True)