1161 lines
65 KiB
Python
1161 lines
65 KiB
Python
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.routing import Mount
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
|
|
|
from backend.app.main import app
|
|
|
|
|
|
class UiSmokeGoldenTest(unittest.TestCase):
|
|
def _ui_mount(self) -> Mount:
|
|
for route in app.routes:
|
|
if isinstance(route, Mount) and route.path == "/ui":
|
|
return route
|
|
self.fail("Expected /ui mount to be registered")
|
|
|
|
def _extract_js_function(self, source: str, name: str) -> str:
|
|
marker = f"function {name}("
|
|
start = source.find(marker)
|
|
if start < 0:
|
|
self.fail(f"Expected function {name} in app.js")
|
|
brace_start = source.find("{", start)
|
|
if brace_start < 0:
|
|
self.fail(f"Expected opening brace for 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 function {name}")
|
|
|
|
def _run_app_js_behavior_check(self, app_js: str) -> None:
|
|
functions = "\n\n".join(
|
|
[
|
|
self._extract_js_function(app_js, "paneState"),
|
|
self._extract_js_function(app_js, "currentParentPath"),
|
|
self._extract_js_function(app_js, "baseName"),
|
|
self._extract_js_function(app_js, "prepareParentReturnRestore"),
|
|
self._extract_js_function(app_js, "navigateToParent"),
|
|
self._extract_js_function(app_js, "restoreParentReturnFocus"),
|
|
self._extract_js_function(app_js, "handleKeyboardShortcuts"),
|
|
]
|
|
)
|
|
script = textwrap.dedent(
|
|
f"""
|
|
const assert = (condition, message) => {{
|
|
if (!condition) {{
|
|
throw new Error(message);
|
|
}}
|
|
}};
|
|
|
|
let state = {{
|
|
activePane: "left",
|
|
panes: {{
|
|
left: {{
|
|
currentPath: "storage1/parent/submap",
|
|
showHidden: false,
|
|
selectedItem: null,
|
|
selectedItems: [],
|
|
visibleItems: [],
|
|
currentRowIndex: -1,
|
|
selectionAnchorIndex: null,
|
|
pendingSelectionPath: null,
|
|
returnFocusName: null,
|
|
}},
|
|
right: {{
|
|
currentPath: "/Volumes",
|
|
showHidden: false,
|
|
selectedItem: null,
|
|
selectedItems: [],
|
|
visibleItems: [],
|
|
currentRowIndex: -1,
|
|
selectionAnchorIndex: null,
|
|
pendingSelectionPath: null,
|
|
returnFocusName: null,
|
|
}},
|
|
}},
|
|
}};
|
|
|
|
const navigationCalls = [];
|
|
const renderCalls = [];
|
|
|
|
function navigateTo(pane, path) {{
|
|
navigationCalls.push({{ pane, path }});
|
|
}}
|
|
|
|
function renderPaneItems(pane) {{
|
|
renderCalls.push({{ pane, currentRowIndex: paneState(pane).currentRowIndex }});
|
|
const model = paneState(pane);
|
|
if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) {{
|
|
model.currentRowIndex = -1;
|
|
return;
|
|
}}
|
|
if (model.currentRowIndex < 0 || model.currentRowIndex >= model.visibleItems.length) {{
|
|
model.currentRowIndex = 0;
|
|
}}
|
|
}}
|
|
|
|
function isContextMenuOpen() {{ return false; }}
|
|
function uploadElements() {{ return {{ menuPopup: {{ classList: {{ contains() {{ return true; }} }} }} }}; }}
|
|
function isFeedbackModalOpen() {{ return false; }}
|
|
function closeFeedbackModal() {{}}
|
|
function isDownloadModalOpen() {{ return false; }}
|
|
function closeDownloadModal() {{}}
|
|
function isInfoOpen() {{ return false; }}
|
|
function closeInfo() {{}}
|
|
function isSearchOpen() {{ return false; }}
|
|
function closeSearch() {{}}
|
|
function submitSearch() {{}}
|
|
function isRenamePopupOpen() {{ return false; }}
|
|
function closeRenamePopup() {{}}
|
|
function submitRenamePopup() {{}}
|
|
function isSettingsOpen() {{ return false; }}
|
|
function closeSettings() {{}}
|
|
function isBatchMovePopupOpen() {{ return false; }}
|
|
function closeBatchMovePopup() {{}}
|
|
function submitBatchMovePopup() {{}}
|
|
function isDeleteConfirmModalOpen() {{ return false; }}
|
|
function closeDeleteConfirmModal() {{}}
|
|
function submitDeleteConfirmModal() {{}}
|
|
function isUploadConflictModalOpen() {{ return false; }}
|
|
function resolveUploadConflict() {{}}
|
|
function isMovePopupOpen() {{ return false; }}
|
|
function closeMovePopup() {{}}
|
|
function submitMovePopup() {{}}
|
|
function isEditorOpen() {{ return false; }}
|
|
function attemptCloseEditor() {{}}
|
|
function isImageOpen() {{ return false; }}
|
|
function closeImageViewer() {{}}
|
|
function isVideoOpen() {{ return false; }}
|
|
function closeVideoViewer() {{}}
|
|
function isPdfOpen() {{ return false; }}
|
|
function closePdfViewer() {{}}
|
|
function isViewerOpen() {{ return false; }}
|
|
function closeViewer() {{}}
|
|
function isWildcardPopupOpen() {{ return false; }}
|
|
function shouldHandleShortcut() {{ return true; }}
|
|
function openInfo() {{}}
|
|
function openSearch() {{}}
|
|
function actionShortcutHandled() {{ return false; }}
|
|
function openWildcardPopup() {{}}
|
|
function jumpCurrentRow() {{}}
|
|
function moveCurrentRow() {{}}
|
|
function extendSelectionByRow() {{}}
|
|
function otherPane(pane) {{ return pane === "left" ? "right" : "left"; }}
|
|
function setActivePane(pane) {{ state.activePane = pane; }}
|
|
function openCurrentDirectory() {{}}
|
|
function toggleCurrentSelection() {{}}
|
|
function clearSelectionForActivePane() {{}}
|
|
|
|
{functions}
|
|
|
|
let prevented = false;
|
|
handleKeyboardShortcuts({{
|
|
key: "Backspace",
|
|
code: "Backspace",
|
|
shiftKey: false,
|
|
altKey: false,
|
|
metaKey: false,
|
|
ctrlKey: false,
|
|
target: null,
|
|
preventDefault() {{ prevented = true; }},
|
|
}});
|
|
assert(prevented, "Backspace should prevent default");
|
|
assert(navigationCalls.length === 1, "Backspace should trigger parent navigation");
|
|
assert(navigationCalls[0].path === "storage1/parent", "Backspace should navigate to parent path");
|
|
assert(paneState("left").returnFocusName === "submap", "Backspace should prepare return focus by child name");
|
|
assert(paneState("left").pendingSelectionPath === null, "Backspace parent return must not set pending selection path");
|
|
assert(paneState("left").selectedItems.length === 0, "Backspace parent return must not select items");
|
|
|
|
state.panes.left.currentPath = "storage1/parent/submap";
|
|
state.panes.left.returnFocusName = null;
|
|
state.panes.left.pendingSelectionPath = null;
|
|
navigationCalls.length = 0;
|
|
navigateToParent("left");
|
|
assert(navigationCalls.length === 1, "Mouse parent-up should trigger parent navigation");
|
|
assert(navigationCalls[0].path === "storage1/parent", "Mouse parent-up should navigate to parent path");
|
|
assert(paneState("left").returnFocusName === "submap", "Mouse parent-up should use same restore flow");
|
|
assert(paneState("left").pendingSelectionPath === null, "Mouse parent-up must not set pending selection path");
|
|
|
|
state.panes.left.visibleItems = [
|
|
{{ path: "storage1/parent", name: "..", kind: "directory", isParent: true }},
|
|
{{ path: "storage1/parent/submap", name: "submap", kind: "directory" }},
|
|
{{ path: "storage1/parent/other", name: "other", kind: "directory" }},
|
|
];
|
|
state.panes.left.currentRowIndex = 0;
|
|
state.panes.left.selectedItems = [];
|
|
state.panes.left.selectedItem = null;
|
|
state.panes.left.selectionAnchorIndex = null;
|
|
state.panes.left.returnFocusName = "submap";
|
|
renderCalls.length = 0;
|
|
restoreParentReturnFocus("left", state.panes.left.visibleItems);
|
|
assert(state.panes.left.currentRowIndex === 1, "Restore should focus the child entry in parent");
|
|
assert(state.panes.left.selectedItems.length === 0, "Restore must not add selection");
|
|
assert(state.panes.left.selectedItem === null, "Restore must not set selectedItem");
|
|
assert(state.panes.left.selectionAnchorIndex === null, "Restore must not set selection anchor");
|
|
assert(state.panes.left.returnFocusName === null, "Restore state should be cleared after success");
|
|
assert(renderCalls.length >= 1, "Restore should re-render focused row");
|
|
|
|
state.panes.left.currentRowIndex = 0;
|
|
state.panes.left.selectedItems = [];
|
|
state.panes.left.selectedItem = null;
|
|
state.panes.left.selectionAnchorIndex = null;
|
|
state.panes.left.returnFocusName = "missing";
|
|
renderCalls.length = 0;
|
|
restoreParentReturnFocus("left", state.panes.left.visibleItems);
|
|
assert(state.panes.left.currentRowIndex === 0, "Fallback should keep existing row when match is missing");
|
|
assert(state.panes.left.selectedItems.length === 0, "Fallback must not create selection");
|
|
assert(state.panes.left.selectedItem === null, "Fallback must not set selectedItem");
|
|
assert(state.panes.left.selectionAnchorIndex === null, "Fallback must not set selection anchor");
|
|
assert(state.panes.left.returnFocusName === null, "Restore state should be cleared after failed match");
|
|
"""
|
|
)
|
|
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_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", "delete"]);
|
|
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 === 4, "Only task-based file actions 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 count once it uses the shared task flow");
|
|
assert(activeTaskChipLabel(activeTasks.length) === "4 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 === "4 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 === 4, "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", "delete"]);
|
|
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)
|
|
index_path = Path(mount.app.directory) / "index.html"
|
|
self.assertTrue(index_path.exists())
|
|
|
|
body = index_path.read_text(encoding="utf-8")
|
|
self.assertIn('/ui/assets/img/favicon.svg', body)
|
|
self.assertIn('/ui/base.css', body)
|
|
self.assertIn('/ui/theme-default.css', body)
|
|
self.assertIn('/ui/theme-macos-soft.css', body)
|
|
self.assertIn('/ui/theme-midnight.css', body)
|
|
self.assertIn('/ui/theme-graphite.css', body)
|
|
self.assertIn('/ui/theme-windows11.css', body)
|
|
self.assertIn('/ui/theme-commander-electric.css', body)
|
|
self.assertIn('/ui/theme-nord-arctic.css', body)
|
|
self.assertIn('/ui/theme-catppuccin-soft.css', body)
|
|
self.assertIn('/ui/theme-fluent-neon.css', body)
|
|
self.assertIn('id="workspace"', body)
|
|
self.assertIn('id="footer-bar"', body)
|
|
self.assertIn('id="title-brand"', body)
|
|
self.assertIn('id="title-logo"', body)
|
|
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)
|
|
self.assertIn('id="right-pane"', body)
|
|
self.assertIn('id="left-items"', body)
|
|
self.assertIn('id="right-items"', body)
|
|
self.assertIn('id="left-focus-line"', body)
|
|
self.assertIn('id="right-focus-line"', body)
|
|
self.assertIn('id="function-bar"', body)
|
|
self.assertIn('id="upload-btn"', body)
|
|
self.assertIn('id="upload-menu-toggle"', body)
|
|
self.assertIn('id="upload-menu-popup"', body)
|
|
self.assertIn('id="upload-folder-btn"', body)
|
|
self.assertIn('id="upload-input"', body)
|
|
self.assertIn('id="upload-menu"', body)
|
|
self.assertIn('id="upload-menu-toggle"', body)
|
|
self.assertIn('id="upload-menu-popup"', body)
|
|
self.assertIn('id="upload-folder-btn"', body)
|
|
self.assertIn('id="upload-modal"', body)
|
|
self.assertIn('id="upload-modal-target"', body)
|
|
self.assertIn('id="upload-modal-current-file"', body)
|
|
self.assertIn('id="upload-modal-progress-bar"', body)
|
|
self.assertIn('id="upload-modal-count"', body)
|
|
self.assertIn('id="upload-modal-status"', body)
|
|
self.assertIn('id="upload-modal-cancel-btn"', body)
|
|
self.assertIn('id="feedback-modal"', body)
|
|
self.assertIn('id="feedback-message"', body)
|
|
self.assertIn('id="feedback-close-btn"', body)
|
|
self.assertIn('id="download-modal"', body)
|
|
self.assertIn('id="download-modal-target"', body)
|
|
self.assertIn('id="download-modal-current-file"', body)
|
|
self.assertIn('id="download-modal-progress-bar"', body)
|
|
self.assertIn('id="download-modal-count"', body)
|
|
self.assertIn('id="download-modal-status"', body)
|
|
self.assertIn('id="download-modal-logs-btn"', body)
|
|
self.assertIn('id="download-modal-cancel-btn"', body)
|
|
self.assertIn('id="download-modal-close-btn"', body)
|
|
self.assertIn('id="context-menu"', body)
|
|
self.assertIn('id="context-menu-scope"', body)
|
|
self.assertIn('id="context-menu-target"', body)
|
|
self.assertIn('id="context-menu-open-btn"', body)
|
|
self.assertIn('id="context-menu-edit-btn"', body)
|
|
self.assertIn('id="context-menu-download-btn"', body)
|
|
self.assertIn('id="context-menu-rename-btn"', body)
|
|
self.assertIn('id="context-menu-duplicate-btn"', body)
|
|
self.assertIn('id="context-menu-copy-btn"', body)
|
|
self.assertIn('id="context-menu-move-btn"', body)
|
|
self.assertIn('id="context-menu-delete-btn"', body)
|
|
self.assertIn('id="context-menu-properties-btn"', body)
|
|
self.assertIn('id="settings-btn"', body)
|
|
self.assertIn('id="rename-btn"', body)
|
|
self.assertIn('id="view-btn"', body)
|
|
self.assertIn('id="edit-btn"', body)
|
|
self.assertIn("F1", body)
|
|
self.assertIn("F2", body)
|
|
self.assertIn("F3", body)
|
|
self.assertIn("F4", body)
|
|
self.assertIn("F5", body)
|
|
self.assertIn("F6", body)
|
|
self.assertIn("F7", body)
|
|
self.assertIn("F8", body)
|
|
self.assertIn('id="viewer-modal"', body)
|
|
self.assertIn('id="video-modal"', body)
|
|
self.assertIn('id="pdf-modal"', body)
|
|
self.assertIn('id="image-modal"', body)
|
|
self.assertIn('id="image-viewer-img"', body)
|
|
self.assertIn('id="image-zoom-in-btn"', body)
|
|
self.assertIn('id="image-zoom-out-btn"', body)
|
|
self.assertIn('id="image-reset-btn"', body)
|
|
self.assertIn('id="pdf-frame"', body)
|
|
self.assertIn('id="pdf-close-btn"', body)
|
|
self.assertIn('id="video-player"', body)
|
|
self.assertIn('id="video-close-btn"', body)
|
|
self.assertIn('id="settings-modal"', body)
|
|
self.assertIn('id="search-modal"', body)
|
|
self.assertIn('id="search-input"', body)
|
|
self.assertIn('id="search-results"', body)
|
|
self.assertIn('id="info-modal"', body)
|
|
self.assertIn('id="rename-popup"', body)
|
|
self.assertIn('id="rename-label"', body)
|
|
self.assertIn('id="rename-input"', body)
|
|
self.assertIn('id="rename-apply-btn"', body)
|
|
self.assertIn('id="settings-general-tab"', body)
|
|
self.assertIn('id="settings-interface-tab"', body)
|
|
self.assertIn('id="settings-downloads-tab"', body)
|
|
self.assertIn('id="settings-logs-tab"', body)
|
|
self.assertIn('id="settings-show-thumbnails"', body)
|
|
self.assertIn("Show thumbnails", body)
|
|
self.assertIn('id="settings-selected-theme"', body)
|
|
self.assertIn("Theme", body)
|
|
self.assertIn('value="default"', body)
|
|
self.assertIn('value="macos-soft"', body)
|
|
self.assertIn('value="midnight"', body)
|
|
self.assertIn('value="graphite"', body)
|
|
self.assertIn('value="windows11"', body)
|
|
self.assertIn('value="commander-electric"', body)
|
|
self.assertIn('value="nord-arctic"', body)
|
|
self.assertIn('value="catppuccin-soft"', body)
|
|
self.assertIn('value="fluent-neon"', body)
|
|
self.assertNotIn('id="settings-selected-color-mode"', body)
|
|
self.assertIn('id="settings-startup-path-left"', body)
|
|
self.assertIn('id="settings-startup-path-right"', body)
|
|
self.assertIn("Preferred startup path (left)", body)
|
|
self.assertIn("Preferred startup path (right)", body)
|
|
self.assertIn('id="settings-general-save-btn"', body)
|
|
self.assertIn('id="settings-interface-save-btn"', body)
|
|
self.assertIn('id="settings-downloads-panel"', body)
|
|
self.assertIn('id="settings-download-max-items"', body)
|
|
self.assertIn('id="settings-download-max-total-size"', body)
|
|
self.assertIn('id="settings-download-max-file-size"', body)
|
|
self.assertIn('id="settings-download-scan-timeout"', body)
|
|
self.assertIn('id="settings-download-symlink-policy"', body)
|
|
self.assertIn("ZIP download limits are shown for reference and cannot be changed here.", body)
|
|
self.assertIn('class="settings-activity-grid"', body)
|
|
self.assertIn('class="settings-activity-panel"', body)
|
|
self.assertIn('id="settings-tasks-title"', body)
|
|
self.assertIn('id="settings-history-title"', body)
|
|
self.assertIn('id="settings-tasks-list"', body)
|
|
self.assertIn('id="settings-logs-list"', body)
|
|
self.assertIn('id="viewer-content"', body)
|
|
self.assertIn('id="editor-modal"', body)
|
|
self.assertIn('id="editor-host"', body)
|
|
self.assertIn('id="editor-save-btn"', body)
|
|
self.assertIn('id="editor-cancel-btn"', body)
|
|
self.assertIn('id="move-popup"', body)
|
|
self.assertIn('id="move-input"', body)
|
|
self.assertIn(">Move</h3>", body)
|
|
self.assertIn(">Target path</label>", body)
|
|
self.assertIn('id="batch-move-popup"', body)
|
|
self.assertIn('id="batch-move-apply-btn"', body)
|
|
self.assertIn('id="delete-confirm-modal"', body)
|
|
self.assertIn('id="delete-confirm-message"', body)
|
|
self.assertIn('id="delete-confirm-apply-btn"', body)
|
|
self.assertIn('id="delete-confirm-cancel-btn"', body)
|
|
self.assertIn("Delete folder and contents?", body)
|
|
self.assertIn('id="mkdir-btn"', body)
|
|
self.assertIn('id="copy-btn"', body)
|
|
self.assertIn('id="move-btn"', body)
|
|
self.assertIn('id="rename-btn"', body)
|
|
self.assertIn('id="delete-btn"', body)
|
|
self.assertIn('id="left-breadcrumbs"', body)
|
|
self.assertIn('id="right-breadcrumbs"', body)
|
|
self.assertIn('id="wildcard-popup"', body)
|
|
self.assertIn('id="wildcard-pattern-input"', body)
|
|
self.assertNotIn('id="search-btn"', body)
|
|
self.assertNotIn('id="info-btn"', body)
|
|
self.assertNotIn('id="bookmarks-panel"', body)
|
|
self.assertNotIn('id="tasks-panel"', body)
|
|
|
|
ordered_ids = [
|
|
'id="upload-btn"',
|
|
'id="settings-btn"',
|
|
'id="rename-btn"',
|
|
'id="view-btn"',
|
|
'id="edit-btn"',
|
|
'id="copy-btn"',
|
|
'id="move-btn"',
|
|
'id="mkdir-btn"',
|
|
'id="delete-btn"',
|
|
]
|
|
positions = [body.index(marker) for marker in ordered_ids]
|
|
self.assertEqual(positions, sorted(positions))
|
|
|
|
def test_ui_static_assets_are_present_and_mapped(self) -> None:
|
|
mount = self._ui_mount()
|
|
static_root = Path(mount.app.directory)
|
|
self.assertTrue((static_root / "app.js").exists())
|
|
self.assertTrue((static_root / "base.css").exists())
|
|
self.assertTrue((static_root / "theme-default.css").exists())
|
|
self.assertTrue((static_root / "theme-macos-soft.css").exists())
|
|
self.assertTrue((static_root / "theme-midnight.css").exists())
|
|
self.assertTrue((static_root / "theme-graphite.css").exists())
|
|
self.assertTrue((static_root / "theme-windows11.css").exists())
|
|
self.assertTrue((static_root / "theme-commander-electric.css").exists())
|
|
self.assertTrue((static_root / "theme-nord-arctic.css").exists())
|
|
self.assertTrue((static_root / "theme-catppuccin-soft.css").exists())
|
|
self.assertTrue((static_root / "theme-fluent-neon.css").exists())
|
|
base_css = (static_root / "base.css").read_text(encoding="utf-8")
|
|
self.assertIn(".settings-card {", base_css)
|
|
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)
|
|
self.assertIn(".settings-activity-panel {", base_css)
|
|
self.assertIn("@media (max-width: 900px) {", base_css)
|
|
self.assertIn("@media (max-width: 720px) {", base_css)
|
|
self.assertIn("width: min(1180px, calc(100vw - 20px));", base_css)
|
|
app_js = (static_root / "app.js").read_text(encoding="utf-8")
|
|
self.assertIn('currentPath: "/Volumes"', app_js)
|
|
self.assertIn('returnFocusName: null,', app_js)
|
|
self.assertIn('selectedTheme: "default"', app_js)
|
|
self.assertIn('selectedColorMode: "dark"', app_js)
|
|
self.assertIn('const VALID_THEME_FAMILIES = [', app_js)
|
|
self.assertIn('"commander-electric"', app_js)
|
|
self.assertIn('"nord-arctic"', app_js)
|
|
self.assertIn('"catppuccin-soft"', app_js)
|
|
self.assertIn('"fluent-neon"', app_js)
|
|
self.assertIn('document.documentElement.dataset.themeFamily', app_js)
|
|
self.assertIn('document.documentElement.dataset.colorMode', app_js)
|
|
self.assertIn('function effectiveThemeKey(theme, colorMode)', app_js)
|
|
self.assertIn("document.documentElement.dataset.theme", app_js)
|
|
self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js)
|
|
self.assertIn('document.getElementById("upload-btn").onclick = openUploadPicker;', app_js)
|
|
self.assertIn('function feedbackElements()', app_js)
|
|
self.assertIn('function openFeedbackModal(message)', app_js)
|
|
self.assertIn('function closeFeedbackModal()', app_js)
|
|
self.assertIn('function openConfirmModal({ title, message, path, applyText = "Confirm" })', app_js)
|
|
self.assertIn('function openTextInputModal({ title, label, applyText, initialValue = "", onSubmit })', app_js)
|
|
self.assertIn('function downloadModalElements()', app_js)
|
|
self.assertIn('function isZipDownloadSelection(items)', app_js)
|
|
self.assertIn('function singleFileDownloadRequestKey(path)', app_js)
|
|
self.assertIn('function archiveTaskStatusLabel(status)', app_js)
|
|
self.assertIn('function archiveTaskCountText(task)', app_js)
|
|
self.assertIn('function archiveTaskCurrentItemText(task)', app_js)
|
|
self.assertIn('function archiveTaskProgressPercent(task)', app_js)
|
|
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", "delete"]);', app_js)
|
|
self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);', app_js)
|
|
self.assertIn("The header chip reflects only user-visible file actions that use the shared task system.", 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)
|
|
self.assertIn('function scheduleSettingsLogsPolling()', app_js)
|
|
self.assertIn('lastHistoryRenderKey: ""', app_js)
|
|
self.assertIn('lastTasksRenderKey: ""', app_js)
|
|
self.assertIn('function openZipDownloadModal(selectedItems)', app_js)
|
|
self.assertIn('function openSingleFileDownloadModal(selectedItem)', app_js)
|
|
self.assertIn('function markZipDownloadReady(fileName)', app_js)
|
|
self.assertIn('function markZipDownloadFailed(err)', app_js)
|
|
self.assertIn('function markZipDownloadCancelled()', app_js)
|
|
self.assertIn('function markSingleFileDownloadRequested(fileName, path)', app_js)
|
|
self.assertIn('function markSingleFileDownloadFailed(err, selectedItem)', app_js)
|
|
self.assertIn('function closeDownloadModal()', app_js)
|
|
self.assertIn('function zipDownloadRequestKey(paths)', app_js)
|
|
self.assertIn('async function createArchiveDownloadTask(paths)', app_js)
|
|
self.assertIn('async function getTaskRequest(taskId)', app_js)
|
|
self.assertIn('async function cancelArchiveDownloadTask(taskId)', app_js)
|
|
self.assertIn('function startArchiveDownload(taskId, fileName)', app_js)
|
|
self.assertIn('async function requestArchiveDownloadCancel()', app_js)
|
|
self.assertIn('async function waitForArchiveDownloadReady(taskId)', app_js)
|
|
self.assertIn('function contextMenuElements()', app_js)
|
|
self.assertIn('function openContextMenu(pane, entry, event)', app_js)
|
|
self.assertIn('function closeContextMenu()', app_js)
|
|
self.assertIn('function isOpenableSelection(item)', app_js)
|
|
self.assertIn('async function downloadFileRequest(paths)', app_js)
|
|
self.assertIn('const zipDownload = isZipDownloadSelection(selectedItems);', app_js)
|
|
self.assertIn('openZipDownloadModal(selectedItems);', app_js)
|
|
self.assertIn('openSingleFileDownloadModal(selected);', app_js)
|
|
self.assertIn('const requestKey = zipDownload ? zipDownloadRequestKey(selectedPaths) : singleFileDownloadRequestKey(selected.path);', app_js)
|
|
self.assertIn('targetText: "Archive download requested"', app_js)
|
|
self.assertIn('targetText: `File download requested: ${selectedItem.name}`', app_js)
|
|
self.assertIn('statusText: "Requested"', app_js)
|
|
self.assertIn('statusText: "Requesting download..."', app_js)
|
|
self.assertIn('countText: "Waiting for archive task"', app_js)
|
|
self.assertIn('countText: "Direct file download"', app_js)
|
|
self.assertIn('targetText: "Archive download task"', app_js)
|
|
self.assertIn('statusText: "Ready"', app_js)
|
|
self.assertIn('countText: "Browser download requested"', app_js)
|
|
self.assertIn('countText: "Archive task failed"', app_js)
|
|
self.assertIn('countText: "Archive task cancelled"', app_js)
|
|
self.assertIn('statusText: "Cancelling download..."', app_js)
|
|
self.assertIn('statusText: `Failed: ${err.message || "Archive download failed"}`', app_js)
|
|
self.assertIn('logsVisible: true,', app_js)
|
|
self.assertIn('return `${task.done_items}/${task.total_items} top-level items`;', app_js)
|
|
self.assertIn('return `Current: ${task.current_item}`;', app_js)
|
|
self.assertIn('downloadProgressState.requestKey === requestKey', app_js)
|
|
self.assertIn('setStatus("Preparing download...");', app_js)
|
|
self.assertIn('setStatus("Requesting download...");', app_js)
|
|
self.assertIn('setStatus(zipDownload ? "Preparing download..." : "Requesting download...");', app_js)
|
|
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)
|
|
self.assertIn('const data = await apiRequest("GET", "/api/tasks");', app_js)
|
|
self.assertIn('return "Ready for download";', app_js)
|
|
self.assertIn('return "Multi-item ZIP";', app_js)
|
|
self.assertIn('details.push(`Current: ${task.current_item}`);', app_js)
|
|
self.assertIn('details.push(`${task.done_items}/${task.total_items} items`);', app_js)
|
|
self.assertIn('if (settingsState.lastHistoryRenderKey === renderKey) {', app_js)
|
|
self.assertIn('if (settingsState.lastTasksRenderKey === renderKey) {', app_js)
|
|
self.assertIn('const scrollTop = elements.logsList.scrollTop;', app_js)
|
|
self.assertIn('const scrollTop = elements.tasksList.scrollTop;', app_js)
|
|
self.assertIn('elements.logsList.scrollTop = scrollTop;', app_js)
|
|
self.assertIn('elements.tasksList.scrollTop = scrollTop;', app_js)
|
|
self.assertIn('if (!settingsState.tasksLoaded) {', app_js)
|
|
self.assertIn('if (!settingsState.logsLoaded) {', app_js)
|
|
self.assertIn('downloadModal.logsButton.onclick = () => {', app_js)
|
|
self.assertIn('openSettings("logs");', app_js)
|
|
self.assertIn('function applyContextMenuSelection()', app_js)
|
|
self.assertIn('function startContextMenuOpen()', app_js)
|
|
self.assertIn('function startContextMenuEdit()', app_js)
|
|
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('const result = await apiRequest("POST", "/api/files/delete", {', app_js)
|
|
self.assertIn('state.selectedTaskId = result.task_id;', app_js)
|
|
self.assertIn('await refreshTasksSnapshot();', app_js)
|
|
self.assertIn('function startContextMenuDuplicate()', app_js)
|
|
self.assertIn('function startContextMenuCopy()', app_js)
|
|
self.assertIn('function startContextMenuMove()', app_js)
|
|
self.assertIn('function startContextMenuDelete()', app_js)
|
|
self.assertIn('function startContextMenuProperties()', app_js)
|
|
self.assertIn('contextMenu.duplicateButton.onclick = startContextMenuDuplicate;', app_js)
|
|
self.assertIn('selectedPathsSet.has(entry.path)', app_js)
|
|
self.assertIn('entry.isParent', app_js)
|
|
self.assertIn('row.oncontextmenu = (event) => {', app_js)
|
|
self.assertIn('event.target.closest("li[data-row-index]")', app_js)
|
|
self.assertIn('if (!row) {', app_js)
|
|
self.assertIn('closeContextMenu();', app_js)
|
|
self.assertIn('elements.openButton.classList.toggle("hidden", isMulti);', app_js)
|
|
self.assertIn('const openableSingle = items.length === 1 && isOpenableSelection(items[0]);', app_js)
|
|
self.assertIn('elements.openButton.disabled = !openableSingle;', app_js)
|
|
self.assertIn('if (item.kind === "directory") {', app_js)
|
|
self.assertIn('return isImageSelection(item) || isVideoSelection(item);', app_js)
|
|
self.assertIn('const editableSingle = items.length === 1 && isEditableSelection(items[0]);', app_js)
|
|
self.assertIn('return [".txt", ".log", ".md", ".yml", ".yaml", ".json", ".js", ".py", ".css", ".html", ".conf"].some((suffix) => lower.endsWith(suffix));', app_js)
|
|
self.assertIn('if (!item || item.kind !== "file") {', app_js)
|
|
self.assertIn('elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file");', app_js)
|
|
self.assertIn('elements.editButton.disabled = !editableSingle;', app_js)
|
|
self.assertIn('const downloadableSelection = items.length > 0;', app_js)
|
|
self.assertIn('elements.downloadButton.classList.remove("hidden");', app_js)
|
|
self.assertIn('elements.downloadButton.disabled = !downloadableSelection;', app_js)
|
|
self.assertIn('elements.renameButton.classList.toggle("hidden", isMulti);', app_js)
|
|
self.assertIn('elements.duplicateButton.classList.remove("hidden");', app_js)
|
|
self.assertIn('elements.duplicateButton.disabled = items.length === 0;', app_js)
|
|
self.assertIn('elements.copyButton.classList.remove("hidden");', app_js)
|
|
self.assertIn('elements.copyButton.disabled = items.length === 0;', app_js)
|
|
self.assertIn('elements.moveButton.classList.remove("hidden");', app_js)
|
|
self.assertIn('elements.propertiesButton.classList.remove("hidden");', app_js)
|
|
self.assertIn('elements.propertiesButton.disabled = items.length === 0;', app_js)
|
|
self.assertIn('openCurrentDirectory();', app_js)
|
|
self.assertIn('openEditor();', app_js)
|
|
self.assertIn('if (selectedItems.length !== 1 || !isEditableSelection(selectedItems[0])) {', app_js)
|
|
self.assertIn('const created = await createArchiveDownloadTask(selectedPaths);', app_js)
|
|
self.assertIn('const task = await waitForArchiveDownloadReady(created.task_id);', app_js)
|
|
self.assertIn('startArchiveDownload(task.id, task.destination);', app_js)
|
|
self.assertIn('const { blob, fileName } = await downloadFileRequest(selectedPaths);', app_js)
|
|
self.assertIn('anchor.download = fileName || selected.name;', app_js)
|
|
self.assertIn('openRenamePopup();', app_js)
|
|
self.assertIn('const result = await createDuplicateTask(selectedItems.map((item) => item.path));', app_js)
|
|
self.assertIn('showActionSummary("Duplicate", 1, 0, null);', app_js)
|
|
self.assertIn('showActionSummary("Duplicate", 0, 1, err.message);', app_js)
|
|
self.assertIn('startCopySelected();', app_js)
|
|
self.assertIn('openF6Flow();', app_js)
|
|
self.assertIn('deleteSelected();', 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)
|
|
self.assertIn('title: "Create Folder"', app_js)
|
|
self.assertIn('title: "Add Bookmark"', app_js)
|
|
self.assertIn('openInfo();', app_js)
|
|
self.assertIn('elements.title.textContent = "Properties";', app_js)
|
|
self.assertIn('if (selectedItems.length > 1) {', app_js)
|
|
self.assertIn('renderInfoField("Selected items", selectedItems.length);', app_js)
|
|
self.assertIn('renderInfoField("Files", fileCount);', app_js)
|
|
self.assertIn('renderInfoField("Directories", directoryCount);', app_js)
|
|
self.assertIn('document.getElementById("copy-btn").disabled = !hasSelection;', app_js)
|
|
self.assertNotIn('Only files are supported for copy', app_js)
|
|
self.assertIn('document.getElementById("upload-menu-toggle").onclick = (event) => {', app_js)
|
|
self.assertIn('document.getElementById("upload-folder-btn").onclick = openFolderPicker;', app_js)
|
|
self.assertNotIn('window.confirm(', app_js)
|
|
self.assertNotIn('window.prompt(', app_js)
|
|
self.assertNotIn('window.alert(', app_js)
|
|
self.assertIn('throw createApiError(response, data);', app_js)
|
|
self.assertIn('function closeUploadMenu()', app_js)
|
|
self.assertIn('function toggleUploadMenu()', app_js)
|
|
self.assertNotIn('if (event.altKey) {', app_js)
|
|
self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js)
|
|
self.assertIn('if (event.key === "Backspace") {', app_js)
|
|
self.assertIn('navigateToParent(state.activePane);', app_js)
|
|
self.assertIn('function navigateToParent(pane) {', app_js)
|
|
self.assertIn('prepareParentReturnRestore(pane, childPath);', app_js)
|
|
self.assertIn('function prepareParentReturnRestore(pane, childPath) {', app_js)
|
|
self.assertIn('model.returnFocusName = baseName(childPath);', app_js)
|
|
self.assertNotIn('model.pendingSelectionPath = childPath;', app_js)
|
|
self.assertIn('function collectDeleteRecursivePaths(selectedItems)', app_js)
|
|
self.assertIn('const confirmed = await openConfirmModal({', app_js)
|
|
self.assertIn('recursivePaths.has(item.path)', app_js)
|
|
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)
|
|
self.assertIn('preferredStartupPathLeft', app_js)
|
|
self.assertIn('preferredStartupPathRight', app_js)
|
|
self.assertIn('zipDownloadLimits', app_js)
|
|
self.assertIn('zip_download_limits', app_js)
|
|
self.assertIn('function renderDownloadSettings()', app_js)
|
|
self.assertIn('function formatBinarySize(bytes)', app_js)
|
|
self.assertIn('function formatSeconds(seconds)', app_js)
|
|
self.assertIn('function formatSymlinkPolicy(policy)', app_js)
|
|
self.assertIn('selected_theme', app_js)
|
|
self.assertIn('selected_color_mode', app_js)
|
|
self.assertNotIn("localStorage", app_js)
|
|
self.assertNotIn("THEME_STORAGE_KEY", app_js)
|
|
self.assertIn('preferred_startup_path_left', app_js)
|
|
self.assertIn('preferred_startup_path_right', app_js)
|
|
self.assertIn('paneState("left").currentPath = settingsState.preferredStartupPathLeft || "/Volumes";', app_js)
|
|
self.assertIn('paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes";', app_js)
|
|
self.assertIn('applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);', app_js)
|
|
self.assertIn('renderPaneItems(pane);', app_js)
|
|
self.assertIn('restoreParentReturnFocus(pane, visibleItems);', app_js)
|
|
self.assertIn('function restoreParentReturnFocus(pane, visibleItems) {', app_js)
|
|
self.assertIn('const returningFromChildName = model.returnFocusName;', app_js)
|
|
self.assertIn('model.returnFocusName = null;', app_js)
|
|
self.assertIn('const returnIndex = visibleItems.findIndex((item) => !item.isParent && item.name === returningFromChildName);', app_js)
|
|
self.assertIn('if (returnIndex < 0) {', app_js)
|
|
self.assertNotIn('setSingleSelectionAtIndex(pane, selectedEntryFromItem(returnItem), returnIndex);', app_js)
|
|
self.assertIn('renderPaneItems(pane);', app_js)
|
|
self.assertIn('settings.interfaceTab.onclick = () => {', app_js)
|
|
self.assertIn('setSettingsTab("interface");', app_js)
|
|
self.assertIn('settings.downloadsTab.onclick = () => {', app_js)
|
|
self.assertIn('setSettingsTab("downloads");', app_js)
|
|
self.assertIn('"/api/settings"', app_js)
|
|
self.assertIn('function uploadElements()', app_js)
|
|
self.assertIn('function openUploadPicker()', app_js)
|
|
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)
|
|
self.assertIn('function buildFolderUploadPlan(files, targetPath)', app_js)
|
|
self.assertIn('function folderDirectoryPaths(plan)', app_js)
|
|
self.assertIn('async function ensureFolderDirectoryExists(path)', app_js)
|
|
self.assertIn('async function executeFolderUploadPlan(plan)', app_js)
|
|
self.assertIn('async function handleFolderSelection(event)', app_js)
|
|
self.assertIn('function deleteConfirmElements()', app_js)
|
|
self.assertIn('function openConfirmModal({ title, message, path, applyText = "Confirm" })', app_js)
|
|
self.assertIn('async function executeDeleteItems(pane, items, recursivePaths)', app_js)
|
|
self.assertIn('async function submitDeleteConfirmModal()', app_js)
|
|
self.assertIn('recursive: recursivePaths.has(item.path)', app_js)
|
|
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.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)
|
|
self.assertIn('uploadElements().input.onchange = handleUploadSelection;', app_js)
|
|
self.assertIn('"/api/files/upload"', app_js)
|
|
self.assertIn('function ensureUploadConflictModal()', app_js)
|
|
self.assertIn('function promptUploadConflict(', app_js)
|
|
self.assertIn('formData.append("overwrite", overwrite ? "true" : "false")', app_js)
|
|
self.assertIn('createButton("Overwrite"', app_js)
|
|
self.assertIn('createButton("Overwrite all"', app_js)
|
|
self.assertIn('createButton("Skip"', app_js)
|
|
self.assertIn('createButton("Skip all"', app_js)
|
|
self.assertIn('createButton("Cancel"', app_js)
|
|
self.assertIn('if (err.code !== "already_exists") {', app_js)
|
|
self.assertIn('if (choice === "overwrite_all") {', app_js)
|
|
self.assertIn('if (uploadState.skipAll) {', app_js)
|
|
self.assertIn('if (choice === "skip_all") {', app_js)
|
|
self.assertIn('uploadState.skipAll = true;', app_js)
|
|
self.assertIn('Upload to: ${uploadState.targetPath}', app_js)
|
|
self.assertIn('Uploading ${total} file', app_js)
|
|
self.assertIn('`/api/files/thumbnail?', app_js)
|
|
self.assertIn("function iconTypeForEntry(entry)", app_js)
|
|
self.assertIn("function mediaIconSvg(type)", app_js)
|
|
self.assertIn('const iconType = iconTypeForEntry(entry);', app_js)
|
|
self.assertIn('function createMediaSlot(entry)', app_js)
|
|
self.assertIn('function createSelectionSlot(pane, entry, index)', app_js)
|
|
self.assertIn('entry-select-slot', app_js)
|
|
self.assertIn('entry-select-toggle', app_js)
|
|
self.assertIn('toggleSelectionAtIndex(pane, selectedEntryFromItem(entry), index);', app_js)
|
|
self.assertIn('function openSearch()', app_js)
|
|
self.assertIn('async function submitSearch()', app_js)
|
|
self.assertIn('async function openInfo()', app_js)
|
|
self.assertIn('function imageElements()', app_js)
|
|
self.assertIn('function isImageSelection(item)', app_js)
|
|
self.assertIn('async function openImageViewer()', app_js)
|
|
self.assertIn('if (isImageSelection(item)) {', app_js)
|
|
self.assertIn('openImageViewer();', app_js)
|
|
self.assertIn('function isImageOpen()', app_js)
|
|
self.assertIn('requestAnimationFrame(() => {', app_js)
|
|
self.assertIn('if (!fitImageToViewport()) {', app_js)
|
|
self.assertIn('if (!viewport.clientWidth || !viewport.clientHeight) {', app_js)
|
|
self.assertIn("`/api/files/image?", app_js)
|
|
self.assertIn('if (isImageSelection(selected)) {', app_js)
|
|
self.assertIn('document.getElementById("info-modal")', app_js)
|
|
self.assertIn("`/api/files/info?", app_js)
|
|
self.assertIn('document.getElementById("search-input")', app_js)
|
|
self.assertIn("`/api/search?", app_js)
|
|
self.assertIn('event.key.toLowerCase() === "f"', app_js)
|
|
self.assertIn('(event.metaKey || event.ctrlKey)', app_js)
|
|
self.assertIn('const isInfoShortcut = event.key === "Enter"', app_js)
|
|
self.assertIn('if (event.key === "F1") {', app_js)
|
|
self.assertIn('if (event.key === "F2") {', app_js)
|
|
self.assertIn('function openSettings(tab = "general")', app_js)
|
|
self.assertIn('function openRenamePopup()', app_js)
|
|
self.assertIn('document.getElementById("rename-btn").onclick = openRenamePopup;', app_js)
|
|
self.assertIn('return triggerActionButton("rename-btn");', app_js)
|
|
self.assertIn('".py"', app_js)
|
|
self.assertIn('function openVideoViewer()', app_js)
|
|
self.assertIn('function openPdfViewer()', app_js)
|
|
self.assertIn('async function loadMonacoModule()', app_js)
|
|
self.assertIn('async function ensureMonacoEditor(path, content)', app_js)
|
|
self.assertIn('function disposeMonacoEditor()', app_js)
|
|
self.assertIn('https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/+esm', app_js)
|
|
self.assertIn('document.getElementById("pdf-modal")', app_js)
|
|
self.assertIn("`/api/files/pdf?", app_js)
|
|
self.assertIn('if (isPdfSelection(selected)) {', app_js)
|
|
self.assertIn('video.player.src = streamUrl;', app_js)
|
|
self.assertIn('document.getElementById("video-close-btn")', app_js)
|
|
self.assertIn('row.ondblclick = (ev) => {', app_js)
|
|
self.assertIn('function openMovePopup()', app_js)
|
|
self.assertIn('document.getElementById("move-btn").onclick = openF6Flow;', app_js)
|
|
self.assertIn('await apiRequest("GET", "/api/history")', app_js)
|
|
self.assertIn('Cross-root directory move is not supported in v1', app_js)
|
|
self.assertIn('Batch directory move is not supported in v1', app_js)
|
|
self.assertIn('Batch move requires all selected items to be in the same root', app_js)
|
|
self.assertIn('destination_base', app_js)
|
|
self.assertIn('sources: selectedItems.map((item) => item.path)', app_js)
|
|
self.assertIn("function rootKeyFromPath(path)", app_js)
|
|
self.assertIn("function isNestedPath(sourcePath, destinationPath)", app_js)
|
|
base_css = (static_root / "base.css").read_text(encoding="utf-8")
|
|
default_theme_css = (static_root / "theme-default.css").read_text(encoding="utf-8")
|
|
macos_theme_css = (static_root / "theme-macos-soft.css").read_text(encoding="utf-8")
|
|
midnight_theme_css = (static_root / "theme-midnight.css").read_text(encoding="utf-8")
|
|
graphite_theme_css = (static_root / "theme-graphite.css").read_text(encoding="utf-8")
|
|
windows_theme_css = (static_root / "theme-windows11.css").read_text(encoding="utf-8")
|
|
commander_theme_css = (static_root / "theme-commander-electric.css").read_text(encoding="utf-8")
|
|
nord_theme_css = (static_root / "theme-nord-arctic.css").read_text(encoding="utf-8")
|
|
catppuccin_theme_css = (static_root / "theme-catppuccin-soft.css").read_text(encoding="utf-8")
|
|
fluent_theme_css = (static_root / "theme-fluent-neon.css").read_text(encoding="utf-8")
|
|
self.assertIn('#theme-toggle', base_css)
|
|
self.assertIn('.settings-card', base_css)
|
|
self.assertIn('.settings-tabs', base_css)
|
|
self.assertIn('.entry-media-slot', base_css)
|
|
self.assertIn('.entry-media-icon.folder', base_css)
|
|
self.assertIn('.entry-media-icon.video', base_css)
|
|
self.assertIn('.entry-media-icon.pdf', base_css)
|
|
self.assertIn('.entry-media-svg', base_css)
|
|
self.assertIn('.entry-media-svg.is-filled', base_css)
|
|
self.assertIn('.entry-media-detail', base_css)
|
|
self.assertIn('.entry-media-icon.file', base_css)
|
|
self.assertIn('.editor-card', base_css)
|
|
self.assertIn('.editor-host', base_css)
|
|
self.assertNotIn('.select-marker', base_css)
|
|
self.assertIn(':root[data-theme-family="default"][data-color-mode="dark"]', default_theme_css)
|
|
self.assertIn(':root[data-theme-family="default"][data-color-mode="light"]', default_theme_css)
|
|
self.assertIn(':root[data-theme-family="macos-soft"][data-color-mode="dark"]', macos_theme_css)
|
|
self.assertIn(':root[data-theme-family="midnight"][data-color-mode="dark"]', midnight_theme_css)
|
|
self.assertIn(':root[data-theme-family="graphite"][data-color-mode="dark"]', graphite_theme_css)
|
|
self.assertIn(':root[data-theme-family="windows11"][data-color-mode="dark"]', windows_theme_css)
|
|
self.assertIn(':root[data-theme-family="commander-electric"][data-color-mode="dark"]', commander_theme_css)
|
|
self.assertIn(':root[data-theme-family="nord-arctic"][data-color-mode="dark"]', nord_theme_css)
|
|
self.assertIn(':root[data-theme-family="catppuccin-soft"][data-color-mode="dark"]', catppuccin_theme_css)
|
|
self.assertIn(':root[data-theme-family="fluent-neon"][data-color-mode="dark"]', fluent_theme_css)
|
|
|
|
app_js_url = app.url_path_for("ui", path="/app.js")
|
|
base_css_url = app.url_path_for("ui", path="/base.css")
|
|
theme_default_url = app.url_path_for("ui", path="/theme-default.css")
|
|
self.assertEqual(app_js_url, "/ui/app.js")
|
|
self.assertEqual(base_css_url, "/ui/base.css")
|
|
self.assertEqual(theme_default_url, "/ui/theme-default.css")
|
|
|
|
def test_parent_return_restore_focuses_without_selecting(self) -> None:
|
|
mount = self._ui_mount()
|
|
app_js = (Path(mount.app.directory) / "app.js").read_text(encoding="utf-8")
|
|
self._run_app_js_behavior_check(app_js)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|