feat: voortgang copy/duplicate/move in headerbar
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -231,6 +231,300 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout)
|
||||
|
||||
def _run_header_task_chip_behavior_check(self, app_js: str) -> None:
|
||||
functions = "\n\n".join(
|
||||
[
|
||||
self._extract_js_function(app_js, "headerTaskElements"),
|
||||
self._extract_js_function(app_js, "formatTaskStatusLabel"),
|
||||
self._extract_js_function(app_js, "inferDownloadTaskContext"),
|
||||
self._extract_js_function(app_js, "formatTaskLine"),
|
||||
self._extract_js_function(app_js, "isActiveTask"),
|
||||
self._extract_js_function(app_js, "activeTasksFromItems"),
|
||||
self._extract_js_function(app_js, "activeTaskChipLabel"),
|
||||
self._extract_js_function(app_js, "headerTaskRenderKey"),
|
||||
self._extract_js_function(app_js, "shouldPollHeaderTasks"),
|
||||
self._extract_js_function(app_js, "stopHeaderTaskPolling"),
|
||||
self._extract_js_function(app_js, "scheduleHeaderTaskPolling"),
|
||||
self._extract_js_function(app_js, "setHeaderTaskPopoverOpen"),
|
||||
self._extract_js_function(app_js, "renderHeaderTaskPopover"),
|
||||
self._extract_js_function(app_js, "renderHeaderTaskChip"),
|
||||
self._extract_js_function(app_js, "updateHeaderTaskState"),
|
||||
]
|
||||
)
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
const assert = (condition, message) => {{
|
||||
if (!condition) {{
|
||||
throw new Error(message);
|
||||
}}
|
||||
}};
|
||||
|
||||
function createClassList(initialHidden = false) {{
|
||||
const names = new Set(initialHidden ? ["hidden"] : []);
|
||||
return {{
|
||||
add(name) {{ names.add(name); }},
|
||||
remove(name) {{ names.delete(name); }},
|
||||
toggle(name, force) {{
|
||||
if (force === undefined) {{
|
||||
if (names.has(name)) {{
|
||||
names.delete(name);
|
||||
}} else {{
|
||||
names.add(name);
|
||||
}}
|
||||
return;
|
||||
}}
|
||||
if (force) {{
|
||||
names.add(name);
|
||||
}} else {{
|
||||
names.delete(name);
|
||||
}}
|
||||
}},
|
||||
contains(name) {{ return names.has(name); }},
|
||||
}};
|
||||
}}
|
||||
|
||||
function createElement(initialHidden = false) {{
|
||||
return {{
|
||||
classList: createClassList(initialHidden),
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
children: [],
|
||||
scrollTop: 0,
|
||||
attributes: {{}},
|
||||
append(...nodes) {{
|
||||
this.children.push(...nodes);
|
||||
}},
|
||||
setAttribute(name, value) {{
|
||||
this.attributes[name] = value;
|
||||
}},
|
||||
}};
|
||||
}}
|
||||
|
||||
const elements = {{
|
||||
"header-task-chip-container": createElement(true),
|
||||
"header-task-chip-btn": createElement(false),
|
||||
"header-task-chip-label": createElement(false),
|
||||
"header-task-popover": createElement(true),
|
||||
"header-task-popover-list": createElement(false),
|
||||
"header-task-logs-btn": createElement(false),
|
||||
}};
|
||||
|
||||
const document = {{
|
||||
getElementById(id) {{
|
||||
return elements[id] || null;
|
||||
}},
|
||||
createElement() {{
|
||||
return createElement(false);
|
||||
}},
|
||||
}};
|
||||
|
||||
const window = {{
|
||||
setTimeout(fn, delay) {{
|
||||
return 1;
|
||||
}},
|
||||
clearTimeout(id) {{}},
|
||||
}};
|
||||
|
||||
function formatModified(value) {{
|
||||
return value || "now";
|
||||
}}
|
||||
|
||||
let headerTaskState = {{
|
||||
activeItems: [],
|
||||
popoverOpen: false,
|
||||
pollTimer: null,
|
||||
lastRenderKey: "",
|
||||
}};
|
||||
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]);
|
||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);
|
||||
|
||||
{functions}
|
||||
|
||||
const mixedTasks = [
|
||||
{{ id: "a", operation: "copy", status: "queued", source: "/src/a", destination: "/dst/a" }},
|
||||
{{ id: "b", operation: "move", status: "running", source: "/src/b", destination: "/dst/b", done_items: 1, total_items: 3, current_item: "b.mkv" }},
|
||||
{{ 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: "e", operation: "copy", status: "completed", source: "/src/e", destination: "/dst/e" }},
|
||||
{{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }},
|
||||
{{ id: "g", operation: "download", status: "ready", source: "/src/g", destination: "folder.zip" }},
|
||||
{{ id: "h", operation: "download", status: "cancelled", source: "/src/h", destination: "folder.zip" }},
|
||||
];
|
||||
|
||||
const activeTasks = activeTasksFromItems(mixedTasks);
|
||||
assert(activeTasks.length === 3, "Only copy, move and duplicate tasks in queued or running 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 not be counted because it is not task-based in the current UI flow");
|
||||
assert(activeTaskChipLabel(activeTasks.length) === "3 active tasks", "Chip label should reflect active task 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 === "3 active tasks", "Chip label should render active task 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 === 3, "Popover should render only active file-action tasks");
|
||||
|
||||
updateHeaderTaskState([
|
||||
{{ id: "z1", operation: "copy", status: "completed", source: "/src/z1", destination: "/dst/z1" }},
|
||||
{{ id: "z2", operation: "move", status: "failed", source: "/src/z2", destination: "/dst/z2" }},
|
||||
{{ id: "z3", operation: "download", status: "ready", source: "/src/z3", destination: "folder.zip" }},
|
||||
]);
|
||||
assert(elements["header-task-chip-container"].classList.contains("hidden"), "Chip should hide when no active tasks remain");
|
||||
assert(!headerTaskState.popoverOpen, "Popover should close when no active tasks remain");
|
||||
assert(elements["header-task-popover"].classList.contains("hidden"), "Popover should be hidden when no active tasks remain");
|
||||
assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "false", "Chip button should reset expanded state when hidden");
|
||||
"""
|
||||
)
|
||||
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 _run_task_snapshot_sync_behavior_check(self, app_js: str) -> None:
|
||||
functions = "\n\n".join(
|
||||
[
|
||||
self._extract_js_function(app_js, "headerTaskElements"),
|
||||
self._extract_js_function(app_js, "formatTaskStatusLabel"),
|
||||
self._extract_js_function(app_js, "inferDownloadTaskContext"),
|
||||
self._extract_js_function(app_js, "formatTaskLine"),
|
||||
self._extract_js_function(app_js, "isActiveTask"),
|
||||
self._extract_js_function(app_js, "activeTasksFromItems"),
|
||||
self._extract_js_function(app_js, "activeTaskChipLabel"),
|
||||
self._extract_js_function(app_js, "headerTaskRenderKey"),
|
||||
self._extract_js_function(app_js, "shouldPollHeaderTasks"),
|
||||
self._extract_js_function(app_js, "stopHeaderTaskPolling"),
|
||||
self._extract_js_function(app_js, "scheduleHeaderTaskPolling"),
|
||||
self._extract_js_function(app_js, "setHeaderTaskPopoverOpen"),
|
||||
self._extract_js_function(app_js, "renderHeaderTaskPopover"),
|
||||
self._extract_js_function(app_js, "renderHeaderTaskChip"),
|
||||
self._extract_js_function(app_js, "updateHeaderTaskState"),
|
||||
self._extract_js_function(app_js, "applyTaskSnapshot"),
|
||||
]
|
||||
)
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
const assert = (condition, message) => {{
|
||||
if (!condition) {{
|
||||
throw new Error(message);
|
||||
}}
|
||||
}};
|
||||
|
||||
function createClassList(initialHidden = false) {{
|
||||
const names = new Set(initialHidden ? ["hidden"] : []);
|
||||
return {{
|
||||
add(name) {{ names.add(name); }},
|
||||
remove(name) {{ names.delete(name); }},
|
||||
toggle(name, force) {{
|
||||
if (force === undefined) {{
|
||||
if (names.has(name)) {{
|
||||
names.delete(name);
|
||||
}} else {{
|
||||
names.add(name);
|
||||
}}
|
||||
return;
|
||||
}}
|
||||
if (force) {{
|
||||
names.add(name);
|
||||
}} else {{
|
||||
names.delete(name);
|
||||
}}
|
||||
}},
|
||||
contains(name) {{ return names.has(name); }},
|
||||
}};
|
||||
}}
|
||||
|
||||
function createElement(initialHidden = false) {{
|
||||
return {{
|
||||
classList: createClassList(initialHidden),
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
children: [],
|
||||
scrollTop: 0,
|
||||
attributes: {{}},
|
||||
append(...nodes) {{
|
||||
this.children.push(...nodes);
|
||||
}},
|
||||
setAttribute(name, value) {{
|
||||
this.attributes[name] = value;
|
||||
}},
|
||||
}};
|
||||
}}
|
||||
|
||||
const elements = {{
|
||||
"header-task-chip-container": createElement(true),
|
||||
"header-task-chip-btn": createElement(false),
|
||||
"header-task-chip-label": createElement(false),
|
||||
"header-task-popover": createElement(true),
|
||||
"header-task-popover-list": createElement(false),
|
||||
"header-task-logs-btn": createElement(false),
|
||||
}};
|
||||
|
||||
const document = {{
|
||||
getElementById(id) {{
|
||||
return elements[id] || null;
|
||||
}},
|
||||
createElement() {{
|
||||
return createElement(false);
|
||||
}},
|
||||
}};
|
||||
|
||||
const window = {{
|
||||
setTimeout(fn, delay) {{
|
||||
return 1;
|
||||
}},
|
||||
clearTimeout(id) {{}},
|
||||
}};
|
||||
|
||||
function formatModified(value) {{
|
||||
return value || "now";
|
||||
}}
|
||||
|
||||
let state = {{ lastTaskCount: 0 }};
|
||||
let headerTaskState = {{
|
||||
activeItems: [],
|
||||
popoverOpen: false,
|
||||
pollTimer: null,
|
||||
lastRenderKey: "",
|
||||
}};
|
||||
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]);
|
||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);
|
||||
|
||||
{functions}
|
||||
|
||||
applyTaskSnapshot([
|
||||
{{ id: "copy-1", operation: "copy", status: "running", source: "/src", destination: "/dst" }},
|
||||
]);
|
||||
assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Running task should make chip visible");
|
||||
assert(elements["header-task-chip-label"].textContent === "1 active task", "Chip should show one active task");
|
||||
assert(headerTaskState.activeItems.length === 1, "Snapshot should store active task state");
|
||||
|
||||
applyTaskSnapshot([
|
||||
{{ id: "copy-1", operation: "copy", status: "completed", source: "/src", destination: "/dst" }},
|
||||
]);
|
||||
assert(elements["header-task-chip-container"].classList.contains("hidden"), "Chip should hide when latest task snapshot has no active tasks");
|
||||
assert(headerTaskState.activeItems.length === 0, "Active task state should be reset when tasks are completed");
|
||||
assert(state.lastTaskCount === 1, "Total task snapshot should still reflect fetched tasks list length");
|
||||
"""
|
||||
)
|
||||
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)
|
||||
@@ -256,6 +550,11 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('/ui/assets/img/logo.svg', body)
|
||||
self.assertIn('id="title-zone-actions"', body)
|
||||
self.assertIn('id="status"', body)
|
||||
self.assertIn('id="header-task-chip-container"', body)
|
||||
self.assertIn('id="header-task-chip-btn"', body)
|
||||
self.assertIn('id="header-task-popover"', body)
|
||||
self.assertIn('id="header-task-popover-list"', body)
|
||||
self.assertIn('id="header-task-logs-btn"', body)
|
||||
self.assertIn('id="theme-toggle"', body)
|
||||
self.assertIn('id="theme-toggle-icon"', body)
|
||||
self.assertIn('id="left-pane"', body)
|
||||
@@ -438,6 +737,10 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn("#title-brand {", base_css)
|
||||
self.assertIn("#title-logo {", base_css)
|
||||
self.assertIn("height: 32px;", base_css)
|
||||
self.assertIn(".header-task-chip-container {", base_css)
|
||||
self.assertIn(".header-task-chip {", base_css)
|
||||
self.assertIn(".header-task-popover {", base_css)
|
||||
self.assertIn(".header-task-popover-list {", base_css)
|
||||
self.assertIn("width: min(1180px, calc(100vw - 32px));", base_css)
|
||||
self.assertIn(".settings-activity-grid {", base_css)
|
||||
self.assertIn("grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);", base_css)
|
||||
@@ -476,6 +779,32 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('function formatTaskStatusLabel(task)', app_js)
|
||||
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"]);', app_js)
|
||||
self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);', app_js)
|
||||
self.assertIn("Delete stays out of this set because it still runs as a direct request flow", app_js)
|
||||
self.assertIn('function headerTaskElements()', app_js)
|
||||
self.assertIn('function isActiveTask(task)', app_js)
|
||||
self.assertIn('function activeTasksFromItems(items)', app_js)
|
||||
self.assertIn('function activeTaskChipLabel(count)', app_js)
|
||||
self.assertIn('function shouldPollHeaderTasks()', app_js)
|
||||
self.assertIn('function scheduleHeaderTaskPolling()', app_js)
|
||||
self.assertIn('function setHeaderTaskPopoverOpen(nextOpen)', app_js)
|
||||
self.assertIn('function renderHeaderTaskPopover(items)', app_js)
|
||||
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('ACTIVE_TASK_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)
|
||||
self.assertIn('headerTasks.chipButton.onclick = (event) => {', app_js)
|
||||
self.assertIn('headerTasks.logsButton.onclick = () => {', app_js)
|
||||
self.assertIn('setHeaderTaskPopoverOpen(!headerTaskState.popoverOpen);', app_js)
|
||||
self.assertIn('setHeaderTaskPopoverOpen(false);', app_js)
|
||||
self.assertIn('updateHeaderTaskState(items);', app_js)
|
||||
self.assertIn('renderTaskItems(applyTaskSnapshot(data.items));', app_js)
|
||||
self.assertIn('function renderTaskItems(items)', app_js)
|
||||
self.assertIn('async function loadTasksForSettings()', app_js)
|
||||
self.assertIn('async function loadLogsAndTasksForSettings()', app_js)
|
||||
@@ -529,6 +858,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('setStatus(`Download requested: ${anchor.download}`);', app_js)
|
||||
self.assertIn('"/api/files/download/archive-prepare"', app_js)
|
||||
self.assertIn('"/api/files/duplicate"', app_js)
|
||||
self.assertIn('"/api/files/delete"', app_js)
|
||||
self.assertIn('`/api/tasks/${encodeURIComponent(taskId)}`', app_js)
|
||||
self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}`', app_js)
|
||||
self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}/cancel`', app_js)
|
||||
@@ -553,6 +883,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('function startContextMenuDownload()', app_js)
|
||||
self.assertIn('function startContextMenuRename()', app_js)
|
||||
self.assertIn('function startDuplicateSelected()', app_js)
|
||||
self.assertIn('async function deleteSelected()', app_js)
|
||||
self.assertIn('function startContextMenuDuplicate()', app_js)
|
||||
self.assertIn('function startContextMenuCopy()', app_js)
|
||||
self.assertIn('function startContextMenuMove()', app_js)
|
||||
@@ -637,6 +968,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('Delete selected items and folder contents?', app_js)
|
||||
self.assertIn('async function loadSettings()', app_js)
|
||||
self.assertIn('await loadSettings();', app_js)
|
||||
self.assertIn('await refreshTasksSnapshot();', app_js)
|
||||
self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js)
|
||||
self.assertIn('settings.generalSaveButton.onclick = handlePreferredStartupPathSave;', app_js)
|
||||
self.assertIn('settings.interfaceSaveButton.onclick = handleInterfaceSave;', app_js)
|
||||
@@ -676,6 +1008,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('function ensureFolderUploadPicker()', app_js)
|
||||
self.assertIn('function openFolderPicker()', app_js)
|
||||
self.assertIn('function uploadModalElements()', app_js)
|
||||
self._run_header_task_chip_behavior_check(app_js)
|
||||
self._run_task_snapshot_sync_behavior_check(app_js)
|
||||
|
||||
self.assertIn('function setUploadModalVisible(', app_js)
|
||||
self.assertIn('function updateUploadModalDisplay(', app_js)
|
||||
|
||||
Reference in New Issue
Block a user