Files

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()