feat: feedback verbetering 05
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -52,6 +53,11 @@ class BlockingMoveFilesystemAdapter(FilesystemAdapter):
|
||||
super().move_file(source, destination)
|
||||
|
||||
|
||||
class CrossDeviceMoveFilesystemAdapter(FilesystemAdapter):
|
||||
def move_file(self, source: str, destination: str) -> None:
|
||||
raise OSError(errno.EXDEV, "Invalid cross-device link")
|
||||
|
||||
|
||||
class MoveApiGoldenTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
@@ -312,6 +318,59 @@ class MoveApiGoldenTest(unittest.TestCase):
|
||||
self.assertTrue((self.root1 / "b.txt").exists())
|
||||
self.assertFalse((target / "b.txt").exists())
|
||||
|
||||
def test_move_batch_cross_root_files_success(self) -> None:
|
||||
first = self.root1 / "first.txt"
|
||||
second = self.root1 / "second.txt"
|
||||
first.write_text("a", encoding="utf-8")
|
||||
second.write_text("b", encoding="utf-8")
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/files/move",
|
||||
{
|
||||
"sources": ["storage1/first.txt", "storage1/second.txt"],
|
||||
"destination_base": "storage2",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
detail = self._wait_task(response.json()["task_id"])
|
||||
self.assertEqual(detail["status"], "completed")
|
||||
self.assertEqual(detail["done_items"], 2)
|
||||
self.assertEqual(detail["total_items"], 2)
|
||||
self.assertTrue((self.root2 / "first.txt").exists())
|
||||
self.assertTrue((self.root2 / "second.txt").exists())
|
||||
self.assertFalse(first.exists())
|
||||
self.assertFalse(second.exists())
|
||||
|
||||
def test_move_batch_cross_root_files_falls_back_from_exdev(self) -> None:
|
||||
first = self.root1 / "first.txt"
|
||||
second = self.root1 / "second.txt"
|
||||
first.write_text("a", encoding="utf-8")
|
||||
second.write_text("b", encoding="utf-8")
|
||||
|
||||
path_guard = PathGuard({"storage1": str(self.root1), "storage2": str(self.root2)})
|
||||
self._set_services(path_guard=path_guard, filesystem=CrossDeviceMoveFilesystemAdapter())
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/files/move",
|
||||
{
|
||||
"sources": ["storage1/first.txt", "storage1/second.txt"],
|
||||
"destination_base": "storage2",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
detail = self._wait_task(response.json()["task_id"])
|
||||
self.assertEqual(detail["status"], "completed")
|
||||
self.assertEqual(detail["done_items"], 2)
|
||||
self.assertEqual(detail["total_items"], 2)
|
||||
self.assertTrue((self.root2 / "first.txt").exists())
|
||||
self.assertTrue((self.root2 / "second.txt").exists())
|
||||
self.assertFalse(first.exists())
|
||||
self.assertFalse(second.exists())
|
||||
|
||||
def test_move_batch_cross_root_directories_blocked(self) -> None:
|
||||
first = self.root1 / "first-dir"
|
||||
second = self.root1 / "second-dir"
|
||||
@@ -329,6 +388,26 @@ class MoveApiGoldenTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"]["code"], "invalid_request")
|
||||
self.assertEqual(response.json()["error"]["message"], "Cross-root batch move with directories is not supported in v1")
|
||||
|
||||
def test_move_batch_cross_root_mixed_files_and_directories_blocked(self) -> None:
|
||||
first = self.root1 / "first.txt"
|
||||
first.write_text("a", encoding="utf-8")
|
||||
second = self.root1 / "second-dir"
|
||||
second.mkdir()
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/files/move",
|
||||
{
|
||||
"sources": ["storage1/first.txt", "storage1/second-dir"],
|
||||
"destination_base": "storage2",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"]["code"], "invalid_request")
|
||||
self.assertEqual(response.json()["error"]["message"], "Cross-root batch move with directories is not supported in v1")
|
||||
|
||||
def test_move_batch_mixed_root_selection_blocked(self) -> None:
|
||||
first = self.root1 / "first-dir"
|
||||
|
||||
@@ -368,7 +368,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
pollTimer: null,
|
||||
lastRenderKey: "",
|
||||
}};
|
||||
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);
|
||||
const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]);
|
||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
|
||||
|
||||
{functions}
|
||||
@@ -388,28 +388,28 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
];
|
||||
|
||||
const activeTasks = activeTasksFromItems(mixedTasks);
|
||||
assert(activeTasks.length === 5, "Only active task-based file actions should count as active");
|
||||
assert(activeTasks.length === 4, "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 count once it uses the shared task flow");
|
||||
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.status === "cancelling"), "Cancelling tasks should remain visible while stopping");
|
||||
assert(activeTaskChipLabel(activeTasks) === "5 active tasks", "Chip label should reflect active task count");
|
||||
assert(activeTaskChipLabel(activeTasks) === "4 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 === "5 active tasks", "Chip label should render active task count");
|
||||
assert(elements["header-task-chip-label"].textContent === "4 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 === 5, "Popover should render only active file-action tasks");
|
||||
assert(elements["header-task-popover-list"].children.length === 4, "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[4];
|
||||
const cancellingRow = elements["header-task-popover-list"].children[3];
|
||||
const cancellingProgress = cancellingRow.children[3];
|
||||
const cancellingCurrent = cancellingRow.children[4];
|
||||
const cancellingSubtext = cancellingRow.children[5];
|
||||
@@ -417,7 +417,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[4].children[6].children[0];
|
||||
const cancellingActionButton = elements["header-task-popover-list"].children[3].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");
|
||||
@@ -582,7 +582,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
pollTimer: null,
|
||||
lastRenderKey: "",
|
||||
}};
|
||||
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);
|
||||
const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]);
|
||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
|
||||
|
||||
{functions}
|
||||
@@ -905,6 +905,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
def test_ui_static_assets_are_present_and_mapped(self) -> None:
|
||||
mount = self._ui_mount()
|
||||
static_root = Path(mount.app.directory)
|
||||
index_html = (static_root / "index.html").read_text(encoding="utf-8")
|
||||
self.assertTrue((static_root / "app.js").exists())
|
||||
self.assertTrue((static_root / "base.css").exists())
|
||||
self.assertTrue((static_root / "theme-default.css").exists())
|
||||
@@ -964,9 +965,9 @@ 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_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);', app_js)
|
||||
self.assertIn('const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]);', app_js)
|
||||
self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);', app_js)
|
||||
self.assertIn("The header chip reflects only user-visible file actions that use the shared task system.", 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)
|
||||
self.assertIn('function isActiveTask(task)', app_js)
|
||||
self.assertIn('function activeTasksFromItems(items)', app_js)
|
||||
@@ -986,12 +987,12 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('function renderHeaderTaskChip(items)', app_js)
|
||||
self.assertIn('function updateHeaderTaskState(taskItems)', app_js)
|
||||
self.assertIn('function applyTaskSnapshot(taskItems)', app_js)
|
||||
self.assertIn('return `${count} active task${count === 1 ? "" : "s"}`;', 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 `${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)
|
||||
self.assertIn('ACTIVE_TASK_OPERATIONS.has(task.operation)', app_js)
|
||||
self.assertIn('ACTIVE_OPERATION_OPERATIONS.has(task.operation)', app_js)
|
||||
self.assertIn('headerTaskState.activeItems = activeTasksFromItems(taskItems);', app_js)
|
||||
self.assertIn('const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0;', app_js)
|
||||
self.assertIn('const headerTasks = headerTaskElements();', app_js)
|
||||
@@ -1135,6 +1136,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('destination_base: baseDestination,', app_js)
|
||||
self.assertIn('setStatus("Copy: operation started");', app_js)
|
||||
self.assertIn('setStatus("Move: operation started");', app_js)
|
||||
self.assertIn('Active operations', index_html)
|
||||
self.assertIn('No active operations right now.', app_js)
|
||||
self.assertIn('const confirmed = await openConfirmModal({', app_js)
|
||||
self.assertIn('title: selectedItems.length === 1 ? "Delete item?" : "Delete selected items?"', app_js)
|
||||
self.assertIn('title: "Discard unsaved changes?"', app_js)
|
||||
|
||||
Reference in New Issue
Block a user