From 61d0c8de416fa3f103168f7da1eceeb247d5916d Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 15 Mar 2026 14:52:33 +0100 Subject: [PATCH] feat: feedback verbetering 04 --- .../test_ui_smoke_golden.cpython-313.pyc | Bin 86000 -> 92297 bytes .../tests/golden/test_ui_smoke_golden.py | 122 ++++++++++++++++++ webui/html/app.js | 74 +++++------ 3 files changed, 154 insertions(+), 42 deletions(-) diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 4bb37681ac048b5277211a7c88daf558a670b6ac..52200db9f808267eabed11ab6d9a728f3f326844 100644 GIT binary patch delta 5131 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 AzW@LL diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index ec09f9a..3ba8b38 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -40,6 +40,25 @@ class UiSmokeGoldenTest(unittest.TestCase): return source[start : index + 1] self.fail(f"Expected closing brace for function {name}") + def _extract_async_js_function(self, source: str, name: str) -> str: + marker = f"async function {name}(" + start = source.find(marker) + if start < 0: + self.fail(f"Expected async function {name} in app.js") + brace_start = source.find("{", start) + if brace_start < 0: + self.fail(f"Expected opening brace for async function {name}") + depth = 0 + for index in range(brace_start, len(source)): + char = source[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[start : index + 1] + self.fail(f"Expected closing brace for async function {name}") + def _run_app_js_behavior_check(self, app_js: str) -> None: functions = "\n\n".join( [ @@ -592,6 +611,104 @@ class UiSmokeGoldenTest(unittest.TestCase): ) self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout) + def _run_operation_start_behavior_check(self, app_js: str) -> None: + functions = "\n\n".join( + [ + 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, "startCopySelected"), + self._extract_async_js_function(app_js, "executeMoveSelection"), + ] + ) + script = textwrap.dedent( + f""" + const assert = (condition, message) => {{ + if (!condition) {{ + throw new Error(message); + }} + }}; + + let state = {{ + activePane: "left", + panes: {{ + left: {{ + currentPath: "storage1/source", + selectedItems: [ + {{ path: "storage1/source/a.txt", kind: "file", name: "a.txt" }}, + {{ path: "storage1/source/b.txt", kind: "file", name: "b.txt" }}, + ], + }}, + right: {{ + currentPath: "storage1/dest", + selectedItems: [], + }}, + }}, + selectedTaskId: null, + }}; + + const apiCalls = []; + const statusMessages = []; + const errorCalls = []; + const refreshCalls = []; + const loadCalls = []; + const clearedSelection = []; + + async function apiRequest(method, url, body) {{ + apiCalls.push({{ method, url, body }}); + return {{ task_id: "task-123", status: "queued" }}; + }} + function setError() {{}} + function setActionError(action, err) {{ errorCalls.push({{ action, message: err.message }}); }} + function setStatus(message) {{ statusMessages.push(message); }} + async function refreshTasksSnapshot() {{ refreshCalls.push(true); }} + async function loadBrowsePane(pane) {{ loadCalls.push(pane); }} + function setSelectedItem(pane, value) {{ clearedSelection.push({{ pane, value }}); }} + + {functions} + + (async () => {{ + await startCopySelected(); + assert(apiCalls.length === 1, "Multi-select copy should issue one request"); + assert(apiCalls[0].url === "/api/files/copy", "Copy should use copy endpoint"); + assert(Array.isArray(apiCalls[0].body.sources), "Copy should send batch sources"); + assert(apiCalls[0].body.sources.length === 2, "Copy batch should include all selected items"); + assert(apiCalls[0].body.destination_base === "storage1/dest", "Copy batch should target destination base"); + assert(state.selectedTaskId === "task-123", "Copy should store the created task id"); + assert(refreshCalls.length === 1, "Copy should refresh task snapshot once"); + assert(statusMessages.includes("Copy: operation started"), "Copy should report operation start"); + + apiCalls.length = 0; + refreshCalls.length = 0; + statusMessages.length = 0; + state.selectedTaskId = null; + + await executeMoveSelection("storage1/dest"); + assert(apiCalls.length === 1, "Multi-select move should issue one request"); + assert(apiCalls[0].url === "/api/files/move", "Move should use move endpoint"); + assert(Array.isArray(apiCalls[0].body.sources), "Move should send batch sources"); + assert(apiCalls[0].body.sources.length === 2, "Move batch should include all selected items"); + assert(apiCalls[0].body.destination_base === "storage1/dest", "Move batch should target destination base"); + assert(state.selectedTaskId === "task-123", "Move should store the created task id"); + 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"); + assert(errorCalls.length === 0, "Batch operation start should not emit action errors"); + }})().catch((error) => {{ + console.error(error); + process.exit(1); + }}); + """ + ) + result = subprocess.run( + ["node", "-e", script], + cwd="/workspace/webmanager-mvp", + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout) + def test_ui_mount_and_index_contains_expected_panels(self) -> None: mount = self._ui_mount() self.assertIsInstance(mount.app, StaticFiles) @@ -1014,6 +1131,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('startCopySelected();', app_js) self.assertIn('openF6Flow();', app_js) self.assertIn('deleteSelected();', app_js) + self.assertIn('sources: selectedItems.map((item) => item.path),', app_js) + 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('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) @@ -1108,6 +1229,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('input.setAttribute("webkitdirectory", "")', app_js) self.assertIn('await apiRequest("POST", "/api/files/mkdir", {', app_js) self.assertIn('await uploadFileRequest(targetPath, entry.file, overwrite);', app_js) + self._run_operation_start_behavior_check(app_js) self.assertIn('Folder upload: preparing', app_js) self.assertIn('Folder upload: ${uploadState.successfulCount} uploaded, ${uploadState.skippedCount} skipped', app_js) self.assertIn('async function handleUploadSelection(event)', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index b6bd5fc..94df0d8 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -2874,28 +2874,30 @@ async function startCopySelected() { } const baseDestination = paneState(destinationPane).currentPath; setError("actions-error", ""); - let successes = 0; - let failures = 0; - let firstError = null; - for (const item of selectedItems) { - const destination = defaultDestination(item.path, baseDestination); - try { - const result = await apiRequest("POST", "/api/files/copy", { + try { + let result; + if (selectedItems.length > 1) { + result = await apiRequest("POST", "/api/files/copy", { + sources: selectedItems.map((item) => item.path), + destination_base: baseDestination, + }); + setStatus("Copy: operation started"); + } else { + const item = selectedItems[0]; + const destination = defaultDestination(item.path, baseDestination); + result = await apiRequest("POST", "/api/files/copy", { source: item.path, destination, }); - state.selectedTaskId = result.task_id; - await refreshTasksSnapshot(); - successes += 1; - } catch (err) { - failures += 1; - if (!firstError) { - firstError = `${item.path}: ${err.message}`; - } + setStatus("Copy: started"); } + state.selectedTaskId = result.task_id; + await refreshTasksSnapshot(); + } catch (err) { + setActionError("Copy", err); + return; } await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); - showActionSummary("Copy", successes, failures, firstError); } async function startMoveSelected() { @@ -2908,10 +2910,9 @@ async function executeMoveSelection(baseDestination) { if (selectedItems.length === 0) { return; } - const allFiles = selectedItems.every((item) => item.kind === "file"); setError("actions-error", ""); - if (!allFiles) { + if (selectedItems.length > 1) { const result = await apiRequest("POST", "/api/files/move", { sources: selectedItems.map((item) => item.path), destination_base: baseDestination, @@ -2920,36 +2921,25 @@ async function executeMoveSelection(baseDestination) { await refreshTasksSnapshot(); setSelectedItem(sourcePane, null); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); - setStatus("Move: batch started"); + setStatus("Move: operation started"); return; } - let successes = 0; - let failures = 0; - let firstError = null; - for (const item of selectedItems) { - const destination = defaultDestination(item.path, baseDestination); - try { - if (item.kind !== "file") { - throw new Error("Only files are supported for move"); - } - const result = await apiRequest("POST", "/api/files/move", { - source: item.path, - destination, - }); - state.selectedTaskId = result.task_id; - await refreshTasksSnapshot(); - successes += 1; - } catch (err) { - failures += 1; - if (!firstError) { - firstError = `${item.path}: ${err.message}`; - } - } + const item = selectedItems[0]; + const destination = defaultDestination(item.path, baseDestination); + if (item.kind !== "file") { + setActionError("Move", new Error("Only files are supported for single-item move")); + return; } + const result = await apiRequest("POST", "/api/files/move", { + source: item.path, + destination, + }); + state.selectedTaskId = result.task_id; + await refreshTasksSnapshot(); setSelectedItem(sourcePane, null); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); - showActionSummary("Move", successes, failures, firstError); + setStatus("Move: started"); } async function addBookmark() {