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:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user