feat: feedback verbetering - 06
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -34,6 +34,8 @@ async def delete(
|
||||
request: DeleteRequest,
|
||||
service: DeleteTaskService = Depends(get_delete_task_service),
|
||||
) -> TaskCreateResponse:
|
||||
if request.paths is not None:
|
||||
return service.create_batch_delete_task(paths=request.paths, recursive_paths=request.recursive_paths or [])
|
||||
return service.create_delete_task(path=request.path, recursive=request.recursive)
|
||||
|
||||
|
||||
|
||||
@@ -51,8 +51,10 @@ class RenameResponse(BaseModel):
|
||||
|
||||
|
||||
class DeleteRequest(BaseModel):
|
||||
path: str
|
||||
path: str | None = None
|
||||
recursive: bool = False
|
||||
paths: list[str] | None = None
|
||||
recursive_paths: list[str] | None = None
|
||||
|
||||
|
||||
class DeleteResponse(BaseModel):
|
||||
|
||||
Binary file not shown.
@@ -25,7 +25,13 @@ class DeleteTaskService:
|
||||
self._runner = runner
|
||||
self._history_repository = history_repository
|
||||
|
||||
def create_delete_task(self, path: str, recursive: bool = False) -> TaskCreateResponse:
|
||||
def create_delete_task(self, path: str | None, recursive: bool = False) -> TaskCreateResponse:
|
||||
if not path:
|
||||
raise AppError(
|
||||
code="invalid_request",
|
||||
message="Query parameter 'path' is required",
|
||||
status_code=400,
|
||||
)
|
||||
try:
|
||||
item = self._build_delete_item(path=path, recursive=recursive)
|
||||
|
||||
@@ -71,11 +77,82 @@ class DeleteTaskService:
|
||||
)
|
||||
raise error
|
||||
|
||||
def _build_delete_item(self, path: str, recursive: bool) -> dict:
|
||||
def create_batch_delete_task(self, paths: list[str] | None, recursive_paths: list[str] | None = None) -> TaskCreateResponse:
|
||||
if not paths or len(paths) < 2:
|
||||
raise AppError(
|
||||
code="invalid_request",
|
||||
message="Batch delete requires at least 2 paths",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
recursive_paths_set = set(recursive_paths or [])
|
||||
invalid_recursive = sorted(path for path in recursive_paths_set if path not in paths)
|
||||
if invalid_recursive:
|
||||
raise AppError(
|
||||
code="invalid_request",
|
||||
message="Recursive delete paths must be included in the batch selection",
|
||||
status_code=400,
|
||||
details={"path": invalid_recursive[0]},
|
||||
)
|
||||
|
||||
try:
|
||||
items = [
|
||||
self._build_delete_item(
|
||||
path=path,
|
||||
recursive=path in recursive_paths_set,
|
||||
include_root_prefix=True,
|
||||
)
|
||||
for path in paths
|
||||
]
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
task = self._repository.create_task(
|
||||
operation="delete",
|
||||
source=f"{len(items)} items",
|
||||
destination="",
|
||||
task_id=task_id,
|
||||
)
|
||||
self._record_history(
|
||||
entry_id=task_id,
|
||||
operation="delete",
|
||||
status="queued",
|
||||
path=f"{len(items)} items",
|
||||
)
|
||||
self._runner.enqueue_delete_batch(task_id=task["id"], items=items)
|
||||
return TaskCreateResponse(task_id=task["id"], status=task["status"])
|
||||
except AppError as exc:
|
||||
self._record_history(
|
||||
operation="delete",
|
||||
status="failed",
|
||||
path=f"{len(paths or [])} items",
|
||||
error_code=exc.code,
|
||||
error_message=exc.message,
|
||||
finished_at=self._now_iso(),
|
||||
)
|
||||
raise
|
||||
except OSError as exc:
|
||||
error = AppError(
|
||||
code="io_error",
|
||||
message="Filesystem operation failed",
|
||||
status_code=500,
|
||||
details={"reason": str(exc)},
|
||||
)
|
||||
self._record_history(
|
||||
operation="delete",
|
||||
status="failed",
|
||||
path=f"{len(paths or [])} items",
|
||||
error_code=error.code,
|
||||
error_message=error.message,
|
||||
finished_at=self._now_iso(),
|
||||
)
|
||||
raise error
|
||||
|
||||
def _build_delete_item(self, path: str, recursive: bool, include_root_prefix: bool = False) -> dict:
|
||||
resolved_target = self._path_guard.resolve_existing_path(path)
|
||||
|
||||
if resolved_target.absolute.is_file():
|
||||
files = [{"path": str(resolved_target.absolute), "label": resolved_target.absolute.name}]
|
||||
label = resolved_target.absolute.name
|
||||
files = [{"path": str(resolved_target.absolute), "label": label}]
|
||||
directories: list[str] = []
|
||||
kind = "file"
|
||||
elif resolved_target.absolute.is_dir():
|
||||
@@ -88,7 +165,10 @@ class DeleteTaskService:
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
if recursive:
|
||||
files, directories = self._build_recursive_delete_plan(resolved_target.absolute)
|
||||
files, directories = self._build_recursive_delete_plan(
|
||||
resolved_target.absolute,
|
||||
include_root_prefix=include_root_prefix,
|
||||
)
|
||||
else:
|
||||
files = []
|
||||
directories = [str(resolved_target.absolute)]
|
||||
@@ -111,9 +191,10 @@ class DeleteTaskService:
|
||||
"progress_label": files[0]["label"] if files else None,
|
||||
}
|
||||
|
||||
def _build_recursive_delete_plan(self, root: Path) -> tuple[list[dict[str, str]], list[str]]:
|
||||
def _build_recursive_delete_plan(self, root: Path, include_root_prefix: bool = False) -> tuple[list[dict[str, str]], list[str]]:
|
||||
files: list[dict[str, str]] = []
|
||||
directories: list[str] = []
|
||||
start_prefix = Path(root.name) if include_root_prefix else Path()
|
||||
|
||||
def walk(path: Path, relative_prefix: Path) -> None:
|
||||
for entry in sorted(path.iterdir(), key=lambda child: child.name.lower()):
|
||||
@@ -127,7 +208,7 @@ class DeleteTaskService:
|
||||
continue
|
||||
files.append({"path": str(entry), "label": relative_path.as_posix()})
|
||||
|
||||
walk(root, Path())
|
||||
walk(root, start_prefix)
|
||||
directories.append(str(root))
|
||||
return files, directories
|
||||
|
||||
|
||||
@@ -89,6 +89,14 @@ class TaskRunner:
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def enqueue_delete_batch(self, task_id: str, items: list[dict[str, object]]) -> None:
|
||||
thread = threading.Thread(
|
||||
target=self._run_delete_batch,
|
||||
args=(task_id, items),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def enqueue_archive_prepare(self, worker) -> None:
|
||||
thread = threading.Thread(
|
||||
target=worker,
|
||||
@@ -490,6 +498,66 @@ class TaskRunner:
|
||||
)
|
||||
self._update_history_failed(task_id, str(exc))
|
||||
|
||||
def _run_delete_batch(self, task_id: str, items: list[dict[str, object]]) -> None:
|
||||
total_items = self._total_delete_work_count(items)
|
||||
current_item = self._first_delete_item_label(items)
|
||||
if not self._repository.mark_running(
|
||||
task_id=task_id,
|
||||
done_items=0,
|
||||
total_items=total_items,
|
||||
current_item=current_item,
|
||||
):
|
||||
self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items)
|
||||
return
|
||||
|
||||
completed_items = 0
|
||||
current_target = str(items[0]["target"]) if items else ""
|
||||
try:
|
||||
for item in items:
|
||||
if self._is_cancel_requested(task_id):
|
||||
self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items)
|
||||
return
|
||||
target = str(item["target"])
|
||||
current_target = target
|
||||
kind = str(item["kind"])
|
||||
recursive = bool(item["recursive"])
|
||||
files = list(item.get("files", [])) # type: ignore[arg-type]
|
||||
directories = list(item.get("directories", [])) # type: ignore[arg-type]
|
||||
if kind == "file":
|
||||
completed_items = self._delete_planned_file(task_id, files[0], completed_items, total_items)
|
||||
elif recursive:
|
||||
for file_entry in files:
|
||||
if self._is_cancel_requested(task_id):
|
||||
self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items)
|
||||
return
|
||||
completed_items = self._delete_planned_file(task_id, file_entry, completed_items, total_items)
|
||||
if self._is_cancel_requested(task_id):
|
||||
self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items)
|
||||
return
|
||||
for directory in directories:
|
||||
self._filesystem.delete_empty_directory(Path(directory))
|
||||
else:
|
||||
self._filesystem.delete_empty_directory(Path(target))
|
||||
self._complete_or_cancel_item_task(
|
||||
task_id=task_id,
|
||||
done_items=completed_items,
|
||||
total_items=total_items,
|
||||
)
|
||||
except OSError as exc:
|
||||
task = self._repository.get_task(task_id)
|
||||
failed_item = (task.get("current_item") if task else None) or current_target
|
||||
self._repository.mark_failed(
|
||||
task_id=task_id,
|
||||
error_code="io_error",
|
||||
error_message=str(exc),
|
||||
failed_item=failed_item,
|
||||
done_bytes=None,
|
||||
total_bytes=None,
|
||||
done_items=completed_items,
|
||||
total_items=total_items,
|
||||
)
|
||||
self._update_history_failed(task_id, str(exc))
|
||||
|
||||
def _cleanup_partial_duplicate(self, path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
@@ -556,6 +624,26 @@ class TaskRunner:
|
||||
return files[0]["label"]
|
||||
return None
|
||||
|
||||
def _total_delete_work_count(self, items: list[dict[str, object]]) -> int:
|
||||
total = 0
|
||||
for item in items:
|
||||
progress_total_items = item.get("progress_total_items")
|
||||
if progress_total_items is not None:
|
||||
total += int(progress_total_items)
|
||||
continue
|
||||
total += len(self._file_entries(item))
|
||||
return total
|
||||
|
||||
def _first_delete_item_label(self, items: list[dict[str, object]]) -> str | None:
|
||||
for item in items:
|
||||
progress_label = item.get("progress_label")
|
||||
if isinstance(progress_label, str) and progress_label:
|
||||
return progress_label
|
||||
files = self._file_entries(item)
|
||||
if files:
|
||||
return files[0]["label"]
|
||||
return None
|
||||
|
||||
def _copy_single_planned_file(
|
||||
self,
|
||||
task_id: str,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -504,6 +504,108 @@ class FileOpsApiGoldenTest(unittest.TestCase):
|
||||
self.assertTrue(target.joinpath("nested", "b.txt").exists())
|
||||
self.assertTrue(target.exists())
|
||||
|
||||
def test_delete_batch_multi_select_starts_one_task_and_completes(self) -> None:
|
||||
first = self.scope / "batch-a.txt"
|
||||
second_dir = self.scope / "batch-dir"
|
||||
second_nested = second_dir / "nested.txt"
|
||||
first.write_text("a", encoding="utf-8")
|
||||
second_dir.mkdir()
|
||||
second_nested.write_text("b", encoding="utf-8")
|
||||
|
||||
response = self._post(
|
||||
"/api/files/delete",
|
||||
{
|
||||
"paths": ["storage1/scope/batch-a.txt", "storage1/scope/batch-dir"],
|
||||
"recursive_paths": ["storage1/scope/batch-dir"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
detail = self._wait_task(response.json()["task_id"])
|
||||
self.assertEqual(detail["operation"], "delete")
|
||||
self.assertEqual(detail["status"], "completed")
|
||||
self.assertEqual(detail["source"], "2 items")
|
||||
self.assertEqual(detail["done_items"], 2)
|
||||
self.assertEqual(detail["total_items"], 2)
|
||||
self.assertFalse(first.exists())
|
||||
self.assertFalse(second_dir.exists())
|
||||
|
||||
def test_delete_batch_cancelled_after_current_delete_finishes(self) -> None:
|
||||
blocking_fs = BlockingDeleteFilesystemAdapter()
|
||||
path_guard = PathGuard({"storage1": str(self.root)})
|
||||
service = FileOpsService(path_guard=path_guard, filesystem=blocking_fs)
|
||||
delete_service = DeleteTaskService(
|
||||
path_guard=path_guard,
|
||||
repository=self.repo,
|
||||
runner=TaskRunner(repository=self.repo, filesystem=blocking_fs),
|
||||
)
|
||||
task_service = TaskService(repository=self.repo)
|
||||
|
||||
async def _override_file_ops_service() -> FileOpsService:
|
||||
return service
|
||||
|
||||
async def _override_delete_task_service() -> DeleteTaskService:
|
||||
return delete_service
|
||||
|
||||
async def _override_task_service() -> TaskService:
|
||||
return task_service
|
||||
|
||||
app.dependency_overrides[get_file_ops_service] = _override_file_ops_service
|
||||
app.dependency_overrides[get_delete_task_service] = _override_delete_task_service
|
||||
app.dependency_overrides[get_task_service] = _override_task_service
|
||||
|
||||
first = self.scope / "cancel-a.txt"
|
||||
second = self.scope / "cancel-b.txt"
|
||||
first.write_text("a", encoding="utf-8")
|
||||
second.write_text("b", encoding="utf-8")
|
||||
|
||||
response = self._post(
|
||||
"/api/files/delete",
|
||||
{
|
||||
"paths": ["storage1/scope/cancel-a.txt", "storage1/scope/cancel-b.txt"],
|
||||
},
|
||||
)
|
||||
|
||||
task_id = response.json()["task_id"]
|
||||
self.assertTrue(blocking_fs.entered.wait(timeout=2.0))
|
||||
running = self._wait_for_status(task_id, {"running"})
|
||||
self.assertEqual(running["done_items"], 0)
|
||||
self.assertEqual(running["total_items"], 2)
|
||||
|
||||
cancel_response = self._post(f"/api/tasks/{task_id}/cancel", {})
|
||||
self.assertEqual(cancel_response.status_code, 200)
|
||||
self.assertEqual(cancel_response.json()["status"], "cancelling")
|
||||
|
||||
blocking_fs.release.set()
|
||||
detail = self._wait_task(task_id)
|
||||
self.assertEqual(detail["status"], "cancelled")
|
||||
self.assertEqual(detail["done_items"], 1)
|
||||
self.assertEqual(detail["total_items"], 2)
|
||||
self.assertFalse(first.exists())
|
||||
self.assertTrue(second.exists())
|
||||
|
||||
def test_delete_batch_directory_only_empty_dirs_remains_honestly_coarse(self) -> None:
|
||||
first = self.scope / "empty-a"
|
||||
second = self.scope / "empty-b"
|
||||
first.mkdir()
|
||||
second.mkdir()
|
||||
|
||||
response = self._post(
|
||||
"/api/files/delete",
|
||||
{
|
||||
"paths": ["storage1/scope/empty-a", "storage1/scope/empty-b"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
detail = self._wait_task(response.json()["task_id"])
|
||||
self.assertEqual(detail["status"], "completed")
|
||||
self.assertEqual(detail["done_items"], 0)
|
||||
self.assertEqual(detail["total_items"], 0)
|
||||
self.assertIsNone(detail["current_item"])
|
||||
self.assertFalse(first.exists())
|
||||
self.assertFalse(second.exists())
|
||||
|
||||
def test_delete_invalid_path(self) -> None:
|
||||
response = self._post(
|
||||
"/api/files/delete",
|
||||
|
||||
@@ -299,6 +299,20 @@ class HistoryApiGoldenTest(unittest.TestCase):
|
||||
self.assertEqual(history[0]['status'], 'completed')
|
||||
self.assertEqual(history[0]['path'], 'storage1/trash.txt')
|
||||
|
||||
def test_delete_batch_completed_history_item(self) -> None:
|
||||
(self.root1 / 'trash-a.txt').write_text('a', encoding='utf-8')
|
||||
(self.root1 / 'trash-b.txt').write_text('b', encoding='utf-8')
|
||||
|
||||
response = self._request('POST', '/api/files/delete', {'paths': ['storage1/trash-a.txt', 'storage1/trash-b.txt']})
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self._wait_task(response.json()['task_id'])
|
||||
|
||||
history = self._request('GET', '/api/history').json()['items']
|
||||
self.assertEqual(history[0]['operation'], 'delete')
|
||||
self.assertEqual(history[0]['status'], 'completed')
|
||||
self.assertEqual(history[0]['path'], '2 items')
|
||||
|
||||
def test_single_file_download_writes_ready_history_item(self) -> None:
|
||||
(self.root1 / 'report.txt').write_text('hello download', encoding='utf-8')
|
||||
|
||||
|
||||
@@ -368,7 +368,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
pollTimer: null,
|
||||
lastRenderKey: "",
|
||||
}};
|
||||
const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]);
|
||||
const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);
|
||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
|
||||
|
||||
{functions}
|
||||
@@ -379,7 +379,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
{{ id: "c", operation: "download", status: "requested", source: "/src/c", destination: "kodidownload-20260315-120000.zip" }},
|
||||
{{ id: "d", operation: "download", status: "preparing", source: "/src/d", destination: "folder.zip" }},
|
||||
{{ id: "dup", operation: "duplicate", status: "queued", source: "/src/dup", destination: "/dst/dup" }},
|
||||
{{ id: "del", operation: "delete", status: "running", source: "/src/del", destination: "" }},
|
||||
{{ id: "del", operation: "delete", status: "running", source: "/src/del", destination: "", done_items: 2, total_items: 5, current_item: "folder/delete-me.txt" }},
|
||||
{{ id: "stop", operation: "copy", status: "cancelling", source: "/src/stop", destination: "/dst/stop", done_items: 1, total_items: 4, current_item: "nested/final-file.txt" }},
|
||||
{{ id: "e", operation: "copy", status: "completed", source: "/src/e", destination: "/dst/e" }},
|
||||
{{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }},
|
||||
@@ -388,28 +388,33 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
];
|
||||
|
||||
const activeTasks = activeTasksFromItems(mixedTasks);
|
||||
assert(activeTasks.length === 4, "Only active user-visible operations should count as active");
|
||||
assert(activeTasks.length === 5, "Only active user-visible operations should count as active");
|
||||
assert(activeTasks.every((task) => isActiveTask(task)), "All filtered tasks should be active");
|
||||
assert(!activeTasks.some((task) => task.operation === "delete"), "Delete should stay out of operation UI until it maps cleanly to one user-visible operation");
|
||||
assert(activeTasks.some((task) => task.operation === "delete"), "Delete should be included once it maps cleanly to one user-visible operation");
|
||||
assert(activeTasks.some((task) => task.status === "cancelling"), "Cancelling tasks should remain visible while stopping");
|
||||
assert(activeTaskChipLabel(activeTasks) === "4 active operations", "Chip label should reflect active operation count");
|
||||
assert(activeTaskChipLabel(activeTasks) === "5 active operations", "Chip label should reflect active operation count");
|
||||
|
||||
updateHeaderTaskState(mixedTasks);
|
||||
assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Chip should be visible with active tasks");
|
||||
assert(elements["header-task-chip-label"].textContent === "4 active operations", "Chip label should render active operation count");
|
||||
assert(elements["header-task-chip-label"].textContent === "5 active operations", "Chip label should render active operation count");
|
||||
assert(shouldPollHeaderTasks(), "Active tasks should enable header polling");
|
||||
|
||||
setHeaderTaskPopoverOpen(true);
|
||||
assert(headerTaskState.popoverOpen, "Popover should open when active tasks exist");
|
||||
assert(!elements["header-task-popover"].classList.contains("hidden"), "Popover should be visible when open");
|
||||
assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "true", "Chip button should expose expanded state");
|
||||
assert(elements["header-task-popover-list"].children.length === 4, "Popover should render only active operations");
|
||||
assert(elements["header-task-popover-list"].children.length === 5, "Popover should render only active operations");
|
||||
const moveRow = elements["header-task-popover-list"].children[1];
|
||||
const moveProgress = moveRow.children[3];
|
||||
const moveCurrent = moveRow.children[4];
|
||||
assert(moveProgress.textContent === "1/3", "Popover should show done/total progress when available");
|
||||
assert(moveCurrent.textContent === "b.mkv", "Popover should show compact current item");
|
||||
const cancellingRow = elements["header-task-popover-list"].children[3];
|
||||
const deleteRow = elements["header-task-popover-list"].children[3];
|
||||
const deleteProgress = deleteRow.children[3];
|
||||
const deleteCurrent = deleteRow.children[4];
|
||||
assert(deleteProgress.textContent === "2/5", "Delete operations should show done/total progress when available");
|
||||
assert(deleteCurrent.textContent === "folder/delete-me.txt", "Delete operations should show compact current item");
|
||||
const cancellingRow = elements["header-task-popover-list"].children[4];
|
||||
const cancellingProgress = cancellingRow.children[3];
|
||||
const cancellingCurrent = cancellingRow.children[4];
|
||||
const cancellingSubtext = cancellingRow.children[5];
|
||||
@@ -417,7 +422,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
assert(cancellingCurrent.textContent === "nested/final-file.txt", "Cancelling tasks should show current item");
|
||||
assert(cancellingSubtext.textContent === "Stopping after current item...", "Cancelling tasks should explain stop semantics");
|
||||
const firstActionButton = elements["header-task-popover-list"].children[0].children[3].children[0];
|
||||
const cancellingActionButton = elements["header-task-popover-list"].children[3].children[6].children[0];
|
||||
const cancellingActionButton = elements["header-task-popover-list"].children[4].children[6].children[0];
|
||||
assert(firstActionButton.textContent === "Stop", "Queued/running tasks should expose a Stop action");
|
||||
assert(!firstActionButton.disabled, "Queued/running tasks should be cancellable");
|
||||
assert(cancellingActionButton.textContent === "Stopping...", "Cancelling tasks should show stopping state");
|
||||
@@ -433,6 +438,11 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
]);
|
||||
assert(elements["header-task-chip-label"].textContent === "Duplicate 3/12", "Single duplicate task should show compact item progress in chip");
|
||||
|
||||
updateHeaderTaskState([
|
||||
{{ id: "single-del", operation: "delete", status: "running", source: "/src/a", destination: "", done_items: 2, total_items: 5, current_item: "nested/file.txt" }},
|
||||
]);
|
||||
assert(elements["header-task-chip-label"].textContent === "Delete 2/5", "Single delete task should show compact item progress in chip");
|
||||
|
||||
updateHeaderTaskState([
|
||||
{{ id: "single-move", operation: "move", status: "running", source: "/src/dir", destination: "/dst/dir", done_items: 0, total_items: 1, current_item: "Folder" }},
|
||||
]);
|
||||
@@ -582,7 +592,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
pollTimer: null,
|
||||
lastRenderKey: "",
|
||||
}};
|
||||
const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]);
|
||||
const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);
|
||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
|
||||
|
||||
{functions}
|
||||
@@ -617,6 +627,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self._extract_js_function(app_js, "paneState"),
|
||||
self._extract_js_function(app_js, "otherPane"),
|
||||
self._extract_js_function(app_js, "defaultDestination"),
|
||||
self._extract_async_js_function(app_js, "executeDeleteItems"),
|
||||
self._extract_async_js_function(app_js, "startCopySelected"),
|
||||
self._extract_async_js_function(app_js, "executeMoveSelection"),
|
||||
]
|
||||
@@ -693,6 +704,30 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
assert(refreshCalls.length === 1, "Move should refresh task snapshot once");
|
||||
assert(statusMessages.includes("Move: operation started"), "Move should report operation start");
|
||||
assert(clearedSelection.length === 1 && clearedSelection[0].pane === "left", "Move batch should clear source selection once");
|
||||
|
||||
apiCalls.length = 0;
|
||||
refreshCalls.length = 0;
|
||||
statusMessages.length = 0;
|
||||
loadCalls.length = 0;
|
||||
clearedSelection.length = 0;
|
||||
state.selectedTaskId = null;
|
||||
|
||||
await executeDeleteItems("left", [
|
||||
{{ path: "storage1/source/a.txt", kind: "file", name: "a.txt" }},
|
||||
{{ path: "storage1/source/folder", kind: "directory", name: "folder" }},
|
||||
], new Set(["storage1/source/folder"]));
|
||||
assert(apiCalls.length === 1, "Multi-select delete should issue one request");
|
||||
assert(apiCalls[0].url === "/api/files/delete", "Delete should use delete endpoint");
|
||||
assert(Array.isArray(apiCalls[0].body.paths), "Delete should send batch paths");
|
||||
assert(apiCalls[0].body.paths.length === 2, "Delete batch should include all selected items");
|
||||
assert(Array.isArray(apiCalls[0].body.recursive_paths), "Delete should send recursive path list");
|
||||
assert(apiCalls[0].body.recursive_paths.length === 1, "Delete batch should include recursive selection paths");
|
||||
assert(apiCalls[0].body.recursive_paths[0] === "storage1/source/folder", "Delete batch should preserve recursive path selection");
|
||||
assert(state.selectedTaskId === "task-123", "Delete should store the created task id");
|
||||
assert(refreshCalls.length === 1, "Delete should refresh task snapshot once");
|
||||
assert(statusMessages.includes("Delete: operation started"), "Delete should report operation start");
|
||||
assert(clearedSelection.length === 1 && clearedSelection[0].pane === "left", "Delete batch should clear source selection once");
|
||||
assert(loadCalls.length === 1 && loadCalls[0] === "left", "Delete batch should reload the source pane once");
|
||||
assert(errorCalls.length === 0, "Batch operation start should not emit action errors");
|
||||
}})().catch((error) => {{
|
||||
console.error(error);
|
||||
@@ -965,7 +1000,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('function inferDownloadTaskContext(task)', app_js)
|
||||
self.assertIn('function formatTaskLine(task)', app_js)
|
||||
self.assertIn('let headerTaskState = {', app_js)
|
||||
self.assertIn('const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]);', app_js)
|
||||
self.assertIn('const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);', app_js)
|
||||
self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);', app_js)
|
||||
self.assertIn("The header chip/popover reflects user-visible file operations, not every task-backed file action.", app_js)
|
||||
self.assertIn('function headerTaskElements()', app_js)
|
||||
@@ -988,7 +1023,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('function updateHeaderTaskState(taskItems)', app_js)
|
||||
self.assertIn('function applyTaskSnapshot(taskItems)', app_js)
|
||||
self.assertIn('return `${count} active operation${count === 1 ? "" : "s"}`;', app_js)
|
||||
self.assertIn('return task.operation === "copy" || task.operation === "duplicate";', app_js)
|
||||
self.assertIn('return task.operation === "copy" || task.operation === "duplicate" || task.operation === "delete";', app_js)
|
||||
self.assertIn('return `${action} ${task.done_items}/${task.total_items}`;', app_js)
|
||||
self.assertIn('return `${action} running`;', app_js)
|
||||
self.assertIn('return "Stopping after current item...";', app_js)
|
||||
@@ -1081,7 +1116,10 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('function startContextMenuRename()', app_js)
|
||||
self.assertIn('function startDuplicateSelected()', app_js)
|
||||
self.assertIn('async function deleteSelected()', app_js)
|
||||
self.assertIn('const result = await apiRequest("POST", "/api/files/delete", {', app_js)
|
||||
self.assertIn('result = await apiRequest("POST", "/api/files/delete", {', app_js)
|
||||
self.assertIn('paths: items.map((item) => item.path),', app_js)
|
||||
self.assertIn('recursive_paths: Array.from(recursivePaths),', app_js)
|
||||
self.assertIn('setStatus("Delete: operation started");', app_js)
|
||||
self.assertIn('state.selectedTaskId = result.task_id;', app_js)
|
||||
self.assertIn('await refreshTasksSnapshot();', app_js)
|
||||
self.assertIn('function startContextMenuDuplicate()', app_js)
|
||||
|
||||
Reference in New Issue
Block a user