1673 lines
57 KiB
Python
1673 lines
57 KiB
Python
import subprocess
|
|
import textwrap
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
class OperationsFrontendTests(unittest.TestCase):
|
|
def test_local_duplicate_maintenance_scan_renders_summary_inline(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
script = textwrap.dedent(
|
|
r"""
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js");
|
|
const source = fs.readFileSync(sourcePath, "utf8");
|
|
|
|
function createButton(action) {
|
|
return {
|
|
handlers: {},
|
|
disabled: false,
|
|
getAttribute(name) {
|
|
if (name === "data-maintenance-action") {
|
|
return action;
|
|
}
|
|
return null;
|
|
},
|
|
addEventListener(type, handler) {
|
|
this.handlers[type] = handler;
|
|
},
|
|
};
|
|
}
|
|
|
|
const statusNode = { textContent: "", className: "" };
|
|
const resultNode = { innerHTML: "" };
|
|
const scanButton = createButton("scan");
|
|
const dedupeButton = createButton("dedupe");
|
|
const panel = {
|
|
attributes: {
|
|
"data-maintenance-panel": "local-duplicates",
|
|
"data-scan-api": "/api/maintenance/local-duplicates",
|
|
"data-dedupe-api": "/api/maintenance/local-duplicates/dedupe",
|
|
},
|
|
getAttribute(name) {
|
|
return this.attributes[name] || "";
|
|
},
|
|
setAttribute(name, value) {
|
|
this.attributes[name] = String(value);
|
|
},
|
|
querySelector(selector) {
|
|
if (selector === '[data-maintenance-action="scan"]') {
|
|
return scanButton;
|
|
}
|
|
if (selector === '[data-maintenance-action="dedupe"]') {
|
|
return dedupeButton;
|
|
}
|
|
if (selector === "[data-maintenance-status]") {
|
|
return statusNode;
|
|
}
|
|
if (selector === "[data-maintenance-result]") {
|
|
return resultNode;
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
|
|
const body = {
|
|
getAttribute(name) {
|
|
return "";
|
|
},
|
|
};
|
|
const document = {
|
|
body,
|
|
querySelector(selector) {
|
|
if (selector === '[data-maintenance-panel="local-duplicates"]') {
|
|
return panel;
|
|
}
|
|
return null;
|
|
},
|
|
querySelectorAll(selector) {
|
|
if (selector === "form[data-json-form]") {
|
|
return [];
|
|
}
|
|
return [];
|
|
},
|
|
createElement() {
|
|
return {
|
|
click() {},
|
|
remove() {},
|
|
};
|
|
},
|
|
};
|
|
const windowObj = {
|
|
Number,
|
|
FormData() {
|
|
return { forEach() {} };
|
|
},
|
|
setTimeout,
|
|
clearTimeout,
|
|
alert() {},
|
|
fetch(url, options) {
|
|
if (url !== "/api/maintenance/local-duplicates") {
|
|
throw new Error("unexpected fetch url: " + url);
|
|
}
|
|
if (options !== undefined) {
|
|
throw new Error("scan should not send fetch options");
|
|
}
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json() {
|
|
return Promise.resolve({
|
|
summary: {
|
|
duplicate_group_count: 2,
|
|
duplicate_location_count: 3,
|
|
scanned_active_local_location_count: 11,
|
|
},
|
|
groups: [
|
|
{
|
|
song_name: "Song A",
|
|
backend_name: "default-local",
|
|
keep: { locator: "Singer A/Song A.flac" },
|
|
duplicates: [{ locator: "Singer A/Song A (1).flac" }],
|
|
},
|
|
],
|
|
});
|
|
},
|
|
});
|
|
},
|
|
EventSource: undefined,
|
|
};
|
|
|
|
global.window = windowObj;
|
|
global.document = document;
|
|
global.setTimeout = windowObj.setTimeout;
|
|
global.clearTimeout = windowObj.clearTimeout;
|
|
eval(source);
|
|
|
|
const clickHandler = scanButton.handlers.click;
|
|
if (typeof clickHandler !== "function") {
|
|
throw new Error("maintenance scan button was not bound");
|
|
}
|
|
|
|
Promise.resolve()
|
|
.then(() => clickHandler({ preventDefault() {} }))
|
|
.then(() => new Promise((resolve) => setTimeout(resolve, 0)))
|
|
.then(() => {
|
|
if (statusNode.textContent.indexOf("2 groups") < 0) {
|
|
throw new Error("unexpected maintenance status: " + statusNode.textContent);
|
|
}
|
|
if (resultNode.innerHTML.indexOf("Singer A/Song A (1).flac") < 0) {
|
|
throw new Error("maintenance result did not render duplicate locator");
|
|
}
|
|
process.exit(0);
|
|
})
|
|
.catch((error) => {
|
|
console.error(error && error.stack ? error.stack : String(error));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
def test_task_rows_refresh_interval_is_one_second_for_more_realtime_tree_updates(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
script = textwrap.dedent(
|
|
r"""
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js");
|
|
let source = fs.readFileSync(sourcePath, "utf8");
|
|
source = source.replace(
|
|
/ bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/,
|
|
" window.__catalogsyncTest = { scheduleTaskRowsRefresh, dashboardState };\n})();"
|
|
);
|
|
if (!source.includes("__catalogsyncTest")) {
|
|
throw new Error("failed to expose dashboard test hooks");
|
|
}
|
|
|
|
let timeoutDelay = null;
|
|
const body = {
|
|
getAttribute(name) {
|
|
if (name === "data-dashboard-api") {
|
|
return "/api/dashboard";
|
|
}
|
|
return "";
|
|
},
|
|
};
|
|
const document = {
|
|
body,
|
|
querySelector() {
|
|
return null;
|
|
},
|
|
querySelectorAll() {
|
|
return [];
|
|
},
|
|
};
|
|
const windowObj = {
|
|
Number,
|
|
clearTimeout() {},
|
|
setTimeout(fn, delay) {
|
|
timeoutDelay = delay;
|
|
return 1;
|
|
},
|
|
alert() {},
|
|
fetch() {},
|
|
};
|
|
|
|
global.window = windowObj;
|
|
global.document = document;
|
|
global.setTimeout = windowObj.setTimeout;
|
|
global.clearTimeout = windowObj.clearTimeout;
|
|
eval(source);
|
|
|
|
const api = window.__catalogsyncTest;
|
|
api.scheduleTaskRowsRefresh();
|
|
|
|
if (timeoutDelay !== 1000) {
|
|
throw new Error("unexpected task rows refresh interval: " + timeoutDelay);
|
|
}
|
|
process.exit(0);
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
def test_force_refresh_keeps_existing_task_tree_content_until_new_payload_arrives(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
script = textwrap.dedent(
|
|
r"""
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js");
|
|
let source = fs.readFileSync(sourcePath, "utf8");
|
|
source = source.replace(
|
|
/ bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/,
|
|
" window.__catalogsyncTest = { dashboardState, loadTaskDetail };\n})();"
|
|
);
|
|
if (!source.includes("__catalogsyncTest")) {
|
|
throw new Error("failed to expose dashboard test hooks");
|
|
}
|
|
|
|
let fetchResolve;
|
|
const childrenNode = {
|
|
innerHTML: "<div>OLD TREE</div>",
|
|
textContent: "",
|
|
hasAttribute(name) {
|
|
return false;
|
|
},
|
|
setAttribute(name, value) {},
|
|
removeAttribute(name) {},
|
|
getAttribute(name) {
|
|
return null;
|
|
},
|
|
querySelector(selector) {
|
|
return null;
|
|
},
|
|
querySelectorAll(selector) {
|
|
return [];
|
|
},
|
|
};
|
|
const body = {
|
|
getAttribute(name) {
|
|
return "";
|
|
},
|
|
};
|
|
const document = {
|
|
body,
|
|
querySelector(selector) {
|
|
if (selector === '[data-task-children="13"]') {
|
|
return childrenNode;
|
|
}
|
|
return null;
|
|
},
|
|
querySelectorAll(selector) {
|
|
return [];
|
|
},
|
|
};
|
|
const windowObj = {
|
|
Number,
|
|
setTimeout,
|
|
clearTimeout,
|
|
alert() {},
|
|
fetch(path) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json() {
|
|
return new Promise((resolve) => {
|
|
fetchResolve = resolve;
|
|
});
|
|
},
|
|
});
|
|
},
|
|
};
|
|
|
|
global.window = windowObj;
|
|
global.document = document;
|
|
|
|
eval(source);
|
|
|
|
const api = window.__catalogsyncTest;
|
|
api.dashboardState.expandedTaskIds["13"] = true;
|
|
api.dashboardState.detailCache["13"] = { job: { id: 13 }, playlist_progress: [] };
|
|
|
|
const promise = api.loadTaskDetail("13", true);
|
|
if (childrenNode.innerHTML !== "<div>OLD TREE</div>") {
|
|
throw new Error("existing task tree content was replaced during refresh: " + childrenNode.innerHTML);
|
|
}
|
|
|
|
Promise.resolve()
|
|
.then(() => {
|
|
if (typeof fetchResolve !== "function") {
|
|
throw new Error("fetch was not triggered for force refresh");
|
|
}
|
|
fetchResolve({ job: { id: 13 }, playlist_progress: [] });
|
|
return promise;
|
|
})
|
|
.then(() => process.exit(0))
|
|
.catch((error) => {
|
|
console.error(error && error.stack ? error.stack : String(error));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
def test_task_toggle_collapses_when_dom_is_visible_even_if_state_drifted(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
script = textwrap.dedent(
|
|
r"""
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js");
|
|
let source = fs.readFileSync(sourcePath, "utf8");
|
|
source = source.replace(
|
|
/ bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/,
|
|
" window.__catalogsyncTest = { dashboardState, bindTaskCenter };\n})();"
|
|
);
|
|
if (!source.includes("__catalogsyncTest")) {
|
|
throw new Error("failed to expose dashboard test hooks");
|
|
}
|
|
|
|
let hidden = false;
|
|
const toggleButton = {
|
|
textContent: "-",
|
|
attributes: { "data-task-toggle": "13", "aria-expanded": "true" },
|
|
getAttribute(name) {
|
|
return this.attributes[name] || null;
|
|
},
|
|
setAttribute(name, value) {
|
|
this.attributes[name] = String(value);
|
|
},
|
|
closest(selector) {
|
|
return selector === "[data-task-toggle]" ? this : null;
|
|
},
|
|
};
|
|
const childrenNode = {
|
|
hasAttribute(name) {
|
|
return name === "hidden" ? hidden : false;
|
|
},
|
|
setAttribute(name, value) {
|
|
if (name === "hidden") {
|
|
hidden = true;
|
|
}
|
|
},
|
|
removeAttribute(name) {
|
|
if (name === "hidden") {
|
|
hidden = false;
|
|
}
|
|
},
|
|
};
|
|
const root = {
|
|
dataset: {},
|
|
addEventListener(type, handler) {
|
|
this.clickHandler = handler;
|
|
},
|
|
};
|
|
const body = {
|
|
getAttribute(name) {
|
|
return "";
|
|
},
|
|
};
|
|
const document = {
|
|
body,
|
|
querySelector(selector) {
|
|
if (selector === "[data-task-tree-root]") {
|
|
return root;
|
|
}
|
|
if (selector === '[data-task-children="13"]') {
|
|
return childrenNode;
|
|
}
|
|
if (selector === '[data-task-toggle="13"]') {
|
|
return toggleButton;
|
|
}
|
|
return null;
|
|
},
|
|
querySelectorAll(selector) {
|
|
return [];
|
|
},
|
|
};
|
|
const windowObj = {
|
|
Number,
|
|
setTimeout,
|
|
clearTimeout,
|
|
alert() {},
|
|
fetch() {
|
|
throw new Error("fetch should not run when collapsing");
|
|
},
|
|
};
|
|
|
|
global.window = windowObj;
|
|
global.document = document;
|
|
eval(source);
|
|
|
|
const api = window.__catalogsyncTest;
|
|
api.dashboardState.expandedTaskIds["13"] = false;
|
|
api.bindTaskCenter();
|
|
|
|
root.clickHandler({ target: toggleButton });
|
|
|
|
if (!hidden) {
|
|
throw new Error("expected visible task children to collapse");
|
|
}
|
|
if (toggleButton.textContent !== "+") {
|
|
throw new Error("expected collapse to reset toggle text");
|
|
}
|
|
process.exit(0);
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
def test_task_toggle_supports_text_node_event_target(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
script = textwrap.dedent(
|
|
r"""
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js");
|
|
let source = fs.readFileSync(sourcePath, "utf8");
|
|
source = source.replace(
|
|
/ bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/,
|
|
" window.__catalogsyncTest = { dashboardState, bindTaskCenter };\n})();"
|
|
);
|
|
if (!source.includes("__catalogsyncTest")) {
|
|
throw new Error("failed to expose dashboard test hooks");
|
|
}
|
|
|
|
let hidden = false;
|
|
const toggleButton = {
|
|
textContent: "-",
|
|
attributes: { "data-task-toggle": "13", "aria-expanded": "true" },
|
|
getAttribute(name) {
|
|
return this.attributes[name] || null;
|
|
},
|
|
setAttribute(name, value) {
|
|
this.attributes[name] = String(value);
|
|
},
|
|
closest(selector) {
|
|
return selector === "[data-task-toggle]" ? this : null;
|
|
},
|
|
};
|
|
const textNode = {
|
|
nodeType: 3,
|
|
parentElement: toggleButton,
|
|
};
|
|
const childrenNode = {
|
|
hasAttribute(name) {
|
|
return name === "hidden" ? hidden : false;
|
|
},
|
|
setAttribute(name, value) {
|
|
if (name === "hidden") {
|
|
hidden = true;
|
|
}
|
|
},
|
|
removeAttribute(name) {
|
|
if (name === "hidden") {
|
|
hidden = false;
|
|
}
|
|
},
|
|
};
|
|
const root = {
|
|
dataset: {},
|
|
addEventListener(type, handler) {
|
|
this.clickHandler = handler;
|
|
},
|
|
};
|
|
const body = {
|
|
getAttribute(name) {
|
|
return "";
|
|
},
|
|
};
|
|
const document = {
|
|
body,
|
|
querySelector(selector) {
|
|
if (selector === '[data-task-tree-root="doing"]') {
|
|
return root;
|
|
}
|
|
if (selector === "[data-task-tree-root]") {
|
|
return root;
|
|
}
|
|
if (selector === '[data-task-children="13"]') {
|
|
return childrenNode;
|
|
}
|
|
if (selector === '[data-task-toggle="13"]') {
|
|
return toggleButton;
|
|
}
|
|
return null;
|
|
},
|
|
querySelectorAll(selector) {
|
|
if (selector === '[data-task-tree-root="doing"], [data-task-tree-root="done"], [data-task-tree-root]') {
|
|
return [root];
|
|
}
|
|
return [];
|
|
},
|
|
};
|
|
const windowObj = {
|
|
Number,
|
|
setTimeout,
|
|
clearTimeout,
|
|
alert() {},
|
|
fetch() {
|
|
throw new Error("fetch should not run when collapsing");
|
|
},
|
|
};
|
|
|
|
global.window = windowObj;
|
|
global.document = document;
|
|
eval(source);
|
|
|
|
const api = window.__catalogsyncTest;
|
|
api.dashboardState.expandedTaskIds["13"] = false;
|
|
api.bindTaskCenter();
|
|
|
|
root.clickHandler({ target: textNode });
|
|
|
|
if (!hidden) {
|
|
throw new Error("expected text-node click to collapse visible task children");
|
|
}
|
|
process.exit(0);
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
def test_split_task_rows_separates_doing_and_done_statuses(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
script = textwrap.dedent(
|
|
r"""
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js");
|
|
let source = fs.readFileSync(sourcePath, "utf8");
|
|
source = source.replace(
|
|
/ bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/,
|
|
" window.__catalogsyncTest = { splitTaskRows };\n})();"
|
|
);
|
|
if (!source.includes("__catalogsyncTest")) {
|
|
throw new Error("failed to expose dashboard test hooks");
|
|
}
|
|
|
|
const body = {
|
|
getAttribute(name) {
|
|
return "";
|
|
},
|
|
};
|
|
const document = {
|
|
body,
|
|
querySelector(selector) {
|
|
return null;
|
|
},
|
|
querySelectorAll(selector) {
|
|
return [];
|
|
},
|
|
};
|
|
const windowObj = {
|
|
Number,
|
|
setTimeout,
|
|
clearTimeout,
|
|
alert() {},
|
|
fetch() {},
|
|
};
|
|
|
|
global.window = windowObj;
|
|
global.document = document;
|
|
eval(source);
|
|
|
|
const api = window.__catalogsyncTest;
|
|
if (typeof api.splitTaskRows !== "function") {
|
|
throw new Error("splitTaskRows hook missing");
|
|
}
|
|
|
|
const result = api.splitTaskRows([
|
|
{ id: 1, status: "running" },
|
|
{ id: 2, status: "queued" },
|
|
{ id: 3, status: "paused" },
|
|
{ id: 4, status: "completed", ended_at: "2026-04-18T10:00:00" },
|
|
{ id: 5, status: "completed_with_errors", ended_at: "2026-04-18T12:00:00" },
|
|
{ id: 6, status: "failed", ended_at: "2026-04-18T09:30:00" },
|
|
{ id: 7, status: "canceled", ended_at: "2026-04-18T11:00:00" },
|
|
]);
|
|
|
|
const doingIds = (result.doing || []).map((row) => row.id).join(",");
|
|
const doneIds = (result.done || []).map((row) => row.id).join(",");
|
|
if (doingIds !== "1,2,3") {
|
|
throw new Error("unexpected doing ids: " + doingIds);
|
|
}
|
|
if (doneIds !== "5,7,4,6") {
|
|
throw new Error("unexpected done ids: " + doneIds);
|
|
}
|
|
process.exit(0);
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
def test_split_task_rows_limits_done_bucket_to_latest_ten(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
script = textwrap.dedent(
|
|
r"""
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js");
|
|
let source = fs.readFileSync(sourcePath, "utf8");
|
|
source = source.replace(
|
|
/ bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/,
|
|
" window.__catalogsyncTest = { splitTaskRows };\n})();"
|
|
);
|
|
if (!source.includes("__catalogsyncTest")) {
|
|
throw new Error("failed to expose dashboard test hooks");
|
|
}
|
|
|
|
const body = {
|
|
getAttribute(name) {
|
|
return "";
|
|
},
|
|
};
|
|
const document = {
|
|
body,
|
|
querySelector() {
|
|
return null;
|
|
},
|
|
querySelectorAll() {
|
|
return [];
|
|
},
|
|
};
|
|
const windowObj = {
|
|
Number,
|
|
setTimeout,
|
|
clearTimeout,
|
|
alert() {},
|
|
fetch() {},
|
|
};
|
|
|
|
global.window = windowObj;
|
|
global.document = document;
|
|
eval(source);
|
|
|
|
const api = window.__catalogsyncTest;
|
|
const rows = [];
|
|
for (let index = 1; index <= 12; index += 1) {
|
|
rows.push({
|
|
id: index,
|
|
status: "completed",
|
|
ended_at: `2026-04-18T${String(index).padStart(2, "0")}:00:00`,
|
|
});
|
|
}
|
|
const doneIds = api.splitTaskRows(rows).done.map((row) => row.id).join(",");
|
|
if (doneIds !== "12,11,10,9,8,7,6,5,4,3") {
|
|
throw new Error("unexpected limited done ids: " + doneIds);
|
|
}
|
|
process.exit(0);
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
def test_render_song_tree_hides_ignored_non_music_resource_rows(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
script = textwrap.dedent(
|
|
r"""
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js");
|
|
let source = fs.readFileSync(sourcePath, "utf8");
|
|
source = source.replace(
|
|
/ bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/,
|
|
" window.__catalogsyncTest = { renderSongTree };\n})();"
|
|
);
|
|
if (!source.includes("__catalogsyncTest")) {
|
|
throw new Error("failed to expose dashboard test hooks");
|
|
}
|
|
|
|
const body = {
|
|
getAttribute() {
|
|
return "";
|
|
},
|
|
};
|
|
const document = {
|
|
body,
|
|
querySelector() {
|
|
return null;
|
|
},
|
|
querySelectorAll() {
|
|
return [];
|
|
},
|
|
};
|
|
const windowObj = {
|
|
Number,
|
|
setTimeout,
|
|
clearTimeout,
|
|
alert() {},
|
|
fetch() {},
|
|
};
|
|
|
|
global.window = windowObj;
|
|
global.document = document;
|
|
eval(source);
|
|
|
|
const api = window.__catalogsyncTest;
|
|
const html = api.renderSongTree([
|
|
{
|
|
song_name: "Ignored Non Music",
|
|
status: "skipped",
|
|
is_non_music_resource: true,
|
|
singers: "Narrator",
|
|
remote_song_id: "qqtop_75_x",
|
|
},
|
|
{
|
|
song_name: "Normal Running Song",
|
|
status: "running",
|
|
is_non_music_resource: false,
|
|
singers: "Singer A",
|
|
remote_song_id: "qq_1",
|
|
},
|
|
]);
|
|
|
|
if (html.includes("Ignored Non Music")) {
|
|
throw new Error("ignored non-music song should not be rendered in doing tree");
|
|
}
|
|
if (!html.includes("Normal Running Song")) {
|
|
throw new Error("normal running song should remain visible");
|
|
}
|
|
process.exit(0);
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
def test_update_dashboard_refreshes_task_center_transfer_summary(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
script = textwrap.dedent(
|
|
r"""
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js");
|
|
let source = fs.readFileSync(sourcePath, "utf8");
|
|
source = source.replace(
|
|
/ bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/,
|
|
" window.__catalogsyncTest = { updateDashboard };\n})();"
|
|
);
|
|
if (!source.includes("__catalogsyncTest")) {
|
|
throw new Error("failed to expose dashboard test hooks");
|
|
}
|
|
|
|
const transferNode = {
|
|
textContent: "before",
|
|
};
|
|
const body = {
|
|
getAttribute() {
|
|
return "";
|
|
},
|
|
};
|
|
const document = {
|
|
body,
|
|
querySelector(selector) {
|
|
if (selector === "[data-task-center-transfer]") {
|
|
return transferNode;
|
|
}
|
|
return null;
|
|
},
|
|
querySelectorAll() {
|
|
return [];
|
|
},
|
|
};
|
|
const windowObj = {
|
|
Number,
|
|
setTimeout,
|
|
clearTimeout,
|
|
alert() {},
|
|
fetch() {},
|
|
};
|
|
|
|
global.window = windowObj;
|
|
global.document = document;
|
|
eval(source);
|
|
|
|
const api = window.__catalogsyncTest;
|
|
api.updateDashboard({
|
|
transfer_stats: {
|
|
download_speed_text: "2.0 MB/s",
|
|
upload_speed_text: "-"
|
|
}
|
|
});
|
|
|
|
if (transferNode.textContent !== "Down 2.0 MB/s | Up -") {
|
|
throw new Error("unexpected transfer summary: " + transferNode.textContent);
|
|
}
|
|
process.exit(0);
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
def test_playlist_export_yaml_serializer_includes_playlist_song_and_location_fields(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
script = textwrap.dedent(
|
|
r"""
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js");
|
|
let source = fs.readFileSync(sourcePath, "utf8");
|
|
source = source.replace(
|
|
/ bindJsonForms\(\);\r?\n bindTaskCenter\(\);\r?\n bindLiveSnapshot\(\);\r?\n bindPlaylistPage\(\);\r?\n refreshDashboardFromApi\(false\);\r?\n\}\)\(\);/,
|
|
" window.__catalogsyncTest = { serializePlaylistExportYaml };\n})();"
|
|
);
|
|
if (!source.includes("__catalogsyncTest")) {
|
|
throw new Error("failed to expose playlist export test hooks");
|
|
}
|
|
|
|
const body = {
|
|
getAttribute() {
|
|
return "";
|
|
},
|
|
};
|
|
const document = {
|
|
body,
|
|
querySelector() {
|
|
return null;
|
|
},
|
|
querySelectorAll() {
|
|
return [];
|
|
},
|
|
};
|
|
const windowObj = {
|
|
Number,
|
|
setTimeout,
|
|
clearTimeout,
|
|
alert() {},
|
|
fetch() {},
|
|
};
|
|
|
|
global.window = windowObj;
|
|
global.document = document;
|
|
eval(source);
|
|
|
|
const api = window.__catalogsyncTest;
|
|
const yaml = api.serializePlaylistExportYaml({
|
|
playlist: {
|
|
id: 88,
|
|
name: "Playlist Export Ready",
|
|
platform: "qq",
|
|
play_count: 7654321
|
|
},
|
|
items: [
|
|
{
|
|
song_id: 801,
|
|
remote_song_id: "65800",
|
|
platform: "netease",
|
|
name: "Song Export Ready",
|
|
singers: "Singer Export",
|
|
ext: "flac",
|
|
file_size_bytes: 1048576,
|
|
local_file_path: "/music/qq/Singer Export/song-export-ready.flac",
|
|
uploaded_locations: [
|
|
{
|
|
backend_name: "catalog-cloud",
|
|
url: "https://cdn.example.invalid/song-export-ready.flac"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
});
|
|
|
|
if (!yaml.includes("playlist_id: 88")) {
|
|
throw new Error("playlist id missing from yaml: " + yaml);
|
|
}
|
|
if (!yaml.includes("platform: qq")) {
|
|
throw new Error("platform missing from yaml: " + yaml);
|
|
}
|
|
if (!yaml.includes("play_count: 7654321")) {
|
|
throw new Error("playlist play_count missing from yaml: " + yaml);
|
|
}
|
|
if (!yaml.includes("local_song_id: 801")) {
|
|
throw new Error("local song id missing from yaml: " + yaml);
|
|
}
|
|
if (!yaml.includes("platform_song_id: 65800")) {
|
|
throw new Error("platform song id missing from yaml: " + yaml);
|
|
}
|
|
if (!yaml.includes("platform: netease")) {
|
|
throw new Error("song platform missing from yaml: " + yaml);
|
|
}
|
|
if (!yaml.includes("local_file_path: /music/qq/Singer Export/song-export-ready.flac")) {
|
|
throw new Error("local file path missing from yaml: " + yaml);
|
|
}
|
|
if (!yaml.includes("uploaded_url: https://cdn.example.invalid/song-export-ready.flac")) {
|
|
throw new Error("uploaded url missing from yaml: " + yaml);
|
|
}
|
|
process.exit(0);
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
def test_playlists_template_uses_export_selected_and_removes_sync_then_download_button(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
html = (repo_root / "musicdl/catalogsync/templates/ops/playlists.html").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
self.assertIn("Download Selected Playlists", html)
|
|
self.assertNotIn("Sync Then Download", html)
|
|
self.assertNotIn('data-playlist-action="sync-download"', html)
|
|
self.assertIn("Export Selected", html)
|
|
self.assertNotIn("Export Selected Playlists", html)
|
|
self.assertIn("data-playlist-export", html)
|
|
self.assertNotIn("Export Folder", html)
|
|
|
|
def test_export_selected_action_uses_download_url_when_backend_returns_ready(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
script = textwrap.dedent(
|
|
r"""
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js");
|
|
const source = fs.readFileSync(sourcePath, "utf8");
|
|
let navigatedTo = "";
|
|
|
|
const exportButton = {
|
|
handlers: {},
|
|
disabled: false,
|
|
getAttribute(name) {
|
|
if (name === "data-playlist-action") return "export-selected";
|
|
return null;
|
|
},
|
|
addEventListener(type, handler) {
|
|
this.handlers[type] = handler;
|
|
},
|
|
};
|
|
const checkbox = { checked: true, value: "101", addEventListener() {} };
|
|
const selectionCount = { textContent: "" };
|
|
const root = {
|
|
querySelectorAll(selector) {
|
|
if (selector === "[data-playlist-checkbox]") return [checkbox];
|
|
if (selector === "[data-playlist-action]") return [exportButton];
|
|
return [];
|
|
},
|
|
querySelector(selector) {
|
|
if (selector === "[data-playlist-selection-count]") return selectionCount;
|
|
return null;
|
|
},
|
|
addEventListener() {},
|
|
};
|
|
const body = {
|
|
getAttribute() {
|
|
return "";
|
|
},
|
|
appendChild() {},
|
|
removeChild() {},
|
|
};
|
|
const document = {
|
|
body,
|
|
querySelector(selector) {
|
|
if (selector === "[data-playlists-page]") return root;
|
|
return null;
|
|
},
|
|
querySelectorAll() {
|
|
return [];
|
|
},
|
|
createElement() {
|
|
return { click() {}, remove() {} };
|
|
},
|
|
};
|
|
const windowObj = {
|
|
Number,
|
|
setTimeout(fn) {
|
|
fn();
|
|
return 1;
|
|
},
|
|
clearTimeout() {},
|
|
alert() {},
|
|
URL: { createObjectURL() { return "blob:test"; }, revokeObjectURL() {} },
|
|
Blob: function Blob() {},
|
|
location: {
|
|
set href(value) { navigatedTo = value; },
|
|
get href() { return navigatedTo; },
|
|
},
|
|
fetch(url) {
|
|
if (url !== "/api/playlists/export-zip") {
|
|
throw new Error("unexpected url: " + url);
|
|
}
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json() {
|
|
return Promise.resolve({
|
|
status: "ready",
|
|
download_url: "/api/exports/bundles/token.zip",
|
|
});
|
|
},
|
|
});
|
|
},
|
|
};
|
|
|
|
global.window = windowObj;
|
|
global.document = document;
|
|
global.setTimeout = windowObj.setTimeout;
|
|
global.clearTimeout = windowObj.clearTimeout;
|
|
eval(source);
|
|
|
|
exportButton.handlers.click({});
|
|
Promise.resolve()
|
|
.then(() => Promise.resolve())
|
|
.then(() => {
|
|
if (navigatedTo !== "/api/exports/bundles/token.zip") {
|
|
throw new Error("unexpected download target: " + navigatedTo);
|
|
}
|
|
process.exit(0);
|
|
})
|
|
.catch((error) => {
|
|
console.error(error && error.stack ? error.stack : String(error));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
def test_playlist_modal_export_click_starts_browser_zip_download(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
script = textwrap.dedent(
|
|
r"""
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js");
|
|
const source = fs.readFileSync(sourcePath, "utf8");
|
|
let navigatedTo = "";
|
|
|
|
const previewTrigger = {
|
|
getAttribute(name) {
|
|
const map = {
|
|
"data-playlist-open-songs": "101",
|
|
"data-playlist-name": "Playlist 101",
|
|
"data-playlist-platform": "qq",
|
|
"data-playlist-remote-id": "remote-101",
|
|
};
|
|
return map[name] || "";
|
|
},
|
|
closest(selector) {
|
|
return selector === "[data-playlist-open-songs]" ? this : null;
|
|
},
|
|
};
|
|
const exportTrigger = {
|
|
closest(selector) {
|
|
return selector === "[data-playlist-export]" ? this : null;
|
|
},
|
|
};
|
|
const exportButton = { disabled: true };
|
|
const modalNode = {
|
|
removeAttribute() {},
|
|
setAttribute() {},
|
|
};
|
|
const modalTitleNode = { textContent: "" };
|
|
const modalMetaNode = { textContent: "", className: "" };
|
|
const modalStateNode = { textContent: "", className: "" };
|
|
const modalTableWrapNode = {
|
|
removeAttribute() {},
|
|
setAttribute() {},
|
|
};
|
|
const modalSongsBodyNode = { innerHTML: "" };
|
|
const root = {
|
|
clickHandler: null,
|
|
querySelectorAll(selector) {
|
|
if (selector === "[data-playlist-checkbox]") return [];
|
|
if (selector === "[data-playlist-action]") return [];
|
|
return [];
|
|
},
|
|
querySelector(selector) {
|
|
if (selector === "[data-playlist-selection-count]") return null;
|
|
if (selector === "[data-playlist-songs-modal]") return modalNode;
|
|
if (selector === "[data-playlist-modal-title]") return modalTitleNode;
|
|
if (selector === "[data-playlist-modal-meta]") return modalMetaNode;
|
|
if (selector === "[data-playlist-modal-state]") return modalStateNode;
|
|
if (selector === "[data-playlist-modal-table-wrap]") return modalTableWrapNode;
|
|
if (selector === "[data-playlist-songs-body]") return modalSongsBodyNode;
|
|
if (selector === "[data-playlist-export]") return exportButton;
|
|
return null;
|
|
},
|
|
addEventListener(type, handler) {
|
|
if (type === "click") {
|
|
this.clickHandler = handler;
|
|
}
|
|
},
|
|
};
|
|
const body = {
|
|
getAttribute() {
|
|
return "";
|
|
},
|
|
appendChild() {},
|
|
removeChild() {},
|
|
};
|
|
const document = {
|
|
body,
|
|
querySelector(selector) {
|
|
if (selector === "[data-playlists-page]") return root;
|
|
return null;
|
|
},
|
|
querySelectorAll() {
|
|
return [];
|
|
},
|
|
createElement() {
|
|
return { click() {}, remove() {} };
|
|
},
|
|
};
|
|
const windowObj = {
|
|
Number,
|
|
setTimeout(fn) {
|
|
fn();
|
|
return 1;
|
|
},
|
|
clearTimeout() {},
|
|
alert() {},
|
|
location: {
|
|
set href(value) { navigatedTo = value; },
|
|
get href() { return navigatedTo; },
|
|
},
|
|
fetch(url) {
|
|
if (url !== "/api/playlists/101/songs") {
|
|
throw new Error("unexpected url: " + url);
|
|
}
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json() {
|
|
return Promise.resolve({
|
|
playlist: {
|
|
id: 101,
|
|
name: "Playlist 101",
|
|
platform: "qq",
|
|
remote_playlist_id: "remote-101",
|
|
},
|
|
items: [
|
|
{
|
|
song_id: 1,
|
|
remote_song_id: "s1",
|
|
platform: "qq",
|
|
name: "Song 1",
|
|
singers: "Singer 1",
|
|
ext: "mp3",
|
|
file_size_bytes: 128,
|
|
local_file_path: "/music/qq/Singer 1/song-1.mp3",
|
|
uploaded_locations: [],
|
|
},
|
|
],
|
|
});
|
|
},
|
|
});
|
|
},
|
|
};
|
|
|
|
global.window = windowObj;
|
|
global.document = document;
|
|
global.setTimeout = windowObj.setTimeout;
|
|
global.clearTimeout = windowObj.clearTimeout;
|
|
eval(source);
|
|
|
|
if (typeof root.clickHandler !== "function") {
|
|
throw new Error("playlist page click handler was not bound");
|
|
}
|
|
|
|
Promise.resolve()
|
|
.then(() => root.clickHandler({ target: previewTrigger }))
|
|
.then(() => Promise.resolve())
|
|
.then(() => {
|
|
root.clickHandler({ target: exportTrigger });
|
|
if (navigatedTo !== "/api/playlists/101/export.zip") {
|
|
throw new Error("unexpected single export target: " + navigatedTo);
|
|
}
|
|
process.exit(0);
|
|
})
|
|
.catch((error) => {
|
|
console.error(error && error.stack ? error.stack : String(error));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
def test_playlist_modal_export_shows_message_when_playlist_is_not_ready(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
script = textwrap.dedent(
|
|
r"""
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js");
|
|
const source = fs.readFileSync(sourcePath, "utf8");
|
|
let alertText = "";
|
|
let navigatedTo = "";
|
|
|
|
const previewTrigger = {
|
|
getAttribute(name) {
|
|
const map = {
|
|
"data-playlist-open-songs": "101",
|
|
"data-playlist-name": "Playlist 101",
|
|
"data-playlist-platform": "qq",
|
|
"data-playlist-remote-id": "remote-101",
|
|
};
|
|
return map[name] || "";
|
|
},
|
|
closest(selector) {
|
|
return selector === "[data-playlist-open-songs]" ? this : null;
|
|
},
|
|
};
|
|
const exportTrigger = {
|
|
closest(selector) {
|
|
return selector === "[data-playlist-export]" ? this : null;
|
|
},
|
|
};
|
|
const exportButton = { disabled: true };
|
|
const modalNode = {
|
|
removeAttribute() {},
|
|
setAttribute() {},
|
|
};
|
|
const modalTitleNode = { textContent: "" };
|
|
const modalMetaNode = { textContent: "", className: "" };
|
|
const modalStateNode = { textContent: "", className: "" };
|
|
const modalTableWrapNode = {
|
|
removeAttribute() {},
|
|
setAttribute() {},
|
|
};
|
|
const modalSongsBodyNode = { innerHTML: "" };
|
|
const root = {
|
|
clickHandler: null,
|
|
querySelectorAll(selector) {
|
|
if (selector === "[data-playlist-checkbox]") return [];
|
|
if (selector === "[data-playlist-action]") return [];
|
|
return [];
|
|
},
|
|
querySelector(selector) {
|
|
if (selector === "[data-playlist-selection-count]") return null;
|
|
if (selector === "[data-playlist-songs-modal]") return modalNode;
|
|
if (selector === "[data-playlist-modal-title]") return modalTitleNode;
|
|
if (selector === "[data-playlist-modal-meta]") return modalMetaNode;
|
|
if (selector === "[data-playlist-modal-state]") return modalStateNode;
|
|
if (selector === "[data-playlist-modal-table-wrap]") return modalTableWrapNode;
|
|
if (selector === "[data-playlist-songs-body]") return modalSongsBodyNode;
|
|
if (selector === "[data-playlist-export]") return exportButton;
|
|
return null;
|
|
},
|
|
addEventListener(type, handler) {
|
|
if (type === "click") {
|
|
this.clickHandler = handler;
|
|
}
|
|
},
|
|
};
|
|
const body = {
|
|
getAttribute() {
|
|
return "";
|
|
},
|
|
appendChild() {},
|
|
removeChild() {},
|
|
};
|
|
const document = {
|
|
body,
|
|
querySelector(selector) {
|
|
if (selector === "[data-playlists-page]") return root;
|
|
return null;
|
|
},
|
|
querySelectorAll() {
|
|
return [];
|
|
},
|
|
createElement() {
|
|
return { click() {}, remove() {} };
|
|
},
|
|
};
|
|
const windowObj = {
|
|
Number,
|
|
setTimeout(fn) {
|
|
fn();
|
|
return 1;
|
|
},
|
|
clearTimeout() {},
|
|
alert(message) {
|
|
alertText = String(message || "");
|
|
},
|
|
location: {
|
|
set href(value) { navigatedTo = value; },
|
|
get href() { return navigatedTo; },
|
|
},
|
|
fetch(url) {
|
|
if (url !== "/api/playlists/101/songs") {
|
|
throw new Error("unexpected url: " + url);
|
|
}
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json() {
|
|
return Promise.resolve({
|
|
playlist: {
|
|
id: 101,
|
|
name: "Playlist 101",
|
|
platform: "qq",
|
|
remote_playlist_id: "remote-101",
|
|
},
|
|
items: [
|
|
{
|
|
song_id: 1,
|
|
remote_song_id: "s1",
|
|
platform: "qq",
|
|
name: "Song 1",
|
|
singers: "Singer 1",
|
|
ext: "mp3",
|
|
file_size_bytes: 128,
|
|
local_file_path: null,
|
|
uploaded_locations: [],
|
|
},
|
|
],
|
|
});
|
|
},
|
|
});
|
|
},
|
|
};
|
|
|
|
global.window = windowObj;
|
|
global.document = document;
|
|
global.setTimeout = windowObj.setTimeout;
|
|
global.clearTimeout = windowObj.clearTimeout;
|
|
eval(source);
|
|
|
|
Promise.resolve()
|
|
.then(() => root.clickHandler({ target: previewTrigger }))
|
|
.then(() => Promise.resolve())
|
|
.then(() => {
|
|
root.clickHandler({ target: exportTrigger });
|
|
if (navigatedTo) {
|
|
throw new Error("unexpected navigation: " + navigatedTo);
|
|
}
|
|
if (alertText.indexOf("not ready") < 0) {
|
|
throw new Error("missing not-ready message: " + alertText);
|
|
}
|
|
process.exit(0);
|
|
})
|
|
.catch((error) => {
|
|
console.error(error && error.stack ? error.stack : String(error));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
def test_download_selected_action_handles_split_download_jobs_without_redirecting_to_single_job(self):
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
script = textwrap.dedent(
|
|
r"""
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const sourcePath = path.join(process.cwd(), "musicdl/catalogsync/static/ops/app.js");
|
|
const source = fs.readFileSync(sourcePath, "utf8");
|
|
let alertText = "";
|
|
let reloaded = false;
|
|
let navigatedTo = "";
|
|
|
|
const downloadButton = {
|
|
handlers: {},
|
|
disabled: false,
|
|
getAttribute(name) {
|
|
if (name === "data-playlist-action") return "download";
|
|
return null;
|
|
},
|
|
addEventListener(type, handler) {
|
|
this.handlers[type] = handler;
|
|
},
|
|
};
|
|
const checkbox = { checked: true, value: "101", addEventListener() {} };
|
|
const selectionCount = { textContent: "" };
|
|
const root = {
|
|
querySelectorAll(selector) {
|
|
if (selector === "[data-playlist-checkbox]") return [checkbox];
|
|
if (selector === "[data-playlist-action]") return [downloadButton];
|
|
return [];
|
|
},
|
|
querySelector(selector) {
|
|
if (selector === "[data-playlist-selection-count]") return selectionCount;
|
|
return null;
|
|
},
|
|
addEventListener() {},
|
|
};
|
|
const locationObj = {
|
|
reload() {
|
|
reloaded = true;
|
|
},
|
|
set href(value) {
|
|
navigatedTo = value;
|
|
},
|
|
get href() {
|
|
return navigatedTo;
|
|
},
|
|
};
|
|
const body = {
|
|
getAttribute() {
|
|
return "";
|
|
},
|
|
appendChild() {},
|
|
removeChild() {},
|
|
};
|
|
const document = {
|
|
body,
|
|
querySelector(selector) {
|
|
if (selector === "[data-playlists-page]") return root;
|
|
return null;
|
|
},
|
|
querySelectorAll() {
|
|
return [];
|
|
},
|
|
createElement() {
|
|
return { click() {}, remove() {} };
|
|
},
|
|
};
|
|
const windowObj = {
|
|
Number,
|
|
setTimeout(fn) {
|
|
fn();
|
|
return 1;
|
|
},
|
|
clearTimeout() {},
|
|
alert(message) {
|
|
alertText = String(message || "");
|
|
},
|
|
location: locationObj,
|
|
fetch(url) {
|
|
if (url !== "/api/playlists/download") {
|
|
throw new Error("unexpected url: " + url);
|
|
}
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json() {
|
|
return Promise.resolve({
|
|
download_job: { id: 11, playlist_scope: { playlist_ids: [101] } },
|
|
sync_download_job: { id: 12, playlist_scope: { playlist_ids: [202] } },
|
|
});
|
|
},
|
|
});
|
|
},
|
|
};
|
|
|
|
global.window = windowObj;
|
|
global.document = document;
|
|
global.setTimeout = windowObj.setTimeout;
|
|
global.clearTimeout = windowObj.clearTimeout;
|
|
eval(source);
|
|
|
|
downloadButton.handlers.click({});
|
|
Promise.resolve()
|
|
.then(() => Promise.resolve())
|
|
.then(() => {
|
|
if (navigatedTo) {
|
|
throw new Error("should not redirect to single job page: " + navigatedTo);
|
|
}
|
|
if (!reloaded) {
|
|
throw new Error("expected page reload after split download jobs");
|
|
}
|
|
if (alertText.indexOf("download job #11") < 0) {
|
|
throw new Error("missing download job message: " + alertText);
|
|
}
|
|
if (alertText.indexOf("sync+download job #12") < 0) {
|
|
throw new Error("missing sync+download job message: " + alertText);
|
|
}
|
|
process.exit(0);
|
|
})
|
|
.catch((error) => {
|
|
console.error(error && error.stack ? error.stack : String(error));
|
|
process.exit(1);
|
|
});
|
|
"""
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=repo_root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
0,
|
|
result.returncode,
|
|
msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|