feedBack/tests/js/edit_metadata_modal.test.js
Byron Gamatos 69aac32278
refactor(app): carve the edit-song modal into static/js/edit-modal.js (R3d) (#919)
4 functions, 234 lines. app.js 4,452 -> 4,218. Bodies VERBATIM.

INTERFACE WIDTH ZERO — nothing in app.js calls into this cluster. app.js needs only the names
on the window contract, so the markup's onclick= handlers resolve. That is what makes it the
cleanest slice left.

AND IT ONLY BECAME CLEAN BECAUSE THE LIBRARY CAME OUT FIRST (#896). Every dependency the modal
has is a module now: it reads six bindings out of ./library.js (loadLibrary, loadFavorites,
loadTreeView, _removeLibCardsForFilename, libView, _lastLibSelected) plus dom.js and the L
container. Before that carve, extracting this would have dragged the whole library with it.

Checked, and it matters: the modal never WRITES any of those six. An imported binding is
READ-ONLY, so a single write would have forced a setter or a state container. Every use is a
read, so plain imports suffice.

Acyclic: edit-modal -> { dom, library-state, library }, and library imports none of them back.

VERIFIED. A/B against origin/main in two browsers: the window contract, the modal actually
OPENING off a real library row, its title and year fields rendering, and the data-edit-save
wiring (rather than an inline onclick embedding the filename — the fix this cluster's harness
exists to guard). IDENTICAL, no new page errors.

node 1045, pytest 2425, ESLint 0 (no-cycle clean), host contract 2/2, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:43:57 +02:00

115 lines
5.7 KiB
JavaScript

// Regression guards for two Edit-Metadata modal fixes (static/js/edit-modal.js):
//
// 1. Year is editable — the modal renders an `edit-year` field and
// saveEditModal() includes `year` in the POST /api/song/<f>/meta body.
// (Backend already accepts/normalizes year; only the UI omitted it.)
//
// 2. A click-drag that starts inside a field and is released on the backdrop
// must NOT dismiss the modal. _editModalShouldClose() gates backdrop
// dismissal on the mousedown having started on the backdrop too.
//
// Functions are extracted from the real shipped source and run in a vm — no
// mirror copies.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
// R3d: the edit modal was carved out of app.js into its own module. Bodies unchanged — only
// the file moved. (It could go cleanly because the LIBRARY came out first: every dependency the
// modal has is a module now, and it reads six library bindings without writing any.)
const EDIT_MODAL_JS = path.join(__dirname, '..', '..', 'static', 'js', 'edit-modal.js');
const readApp = () => fs.readFileSync(EDIT_MODAL_JS, 'utf8');
function loadFn(signature, sandbox, exportAs) {
const fnSrc = extractFunction(readApp(), signature);
const ctx = vm.createContext(sandbox);
vm.runInContext(`${fnSrc}\nglobalThis.${exportAs} = ${exportAs};`, ctx);
return sandbox[exportAs];
}
// ── Issue: Edit Metadata does not allow changing Year ────────────────────────
test('openEditModal renders a Year field bound to songData.y', () => {
const src = extractFunction(readApp(), 'function openEditModal');
assert.match(src, /id="edit-year"/, 'modal must render an #edit-year input');
assert.match(src, /_escAttr\(songData\.y\)/, 'year input must be populated from songData.y');
});
test('Save button wires via data-edit-save, not an inline onclick that embeds the filename', () => {
// encodeURIComponent does NOT escape `'`, so embedding the filename in a
// single-quoted inline `saveEditModal('…')` handler breaks the save for a
// song whose filename contains an apostrophe (e.g. `Bob's Song.sloppak`).
// The Save button must use the data-attr + JS-listener pattern instead.
const src = extractFunction(readApp(), 'function openEditModal');
assert.doesNotMatch(src, /onclick="saveEditModal\('/, 'Save must not embed the filename in an inline onclick');
assert.match(src, /data-edit-save/, 'Save button must carry the data-edit-save hook');
assert.match(src, /querySelector\('\[data-edit-save\]'\)/, 'Save must be wired via addEventListener');
});
test('saveEditModal includes year in the metadata POST body', async () => {
const calls = [];
const values = {
'edit-title': 'My Title', 'edit-artist': 'My Artist',
'edit-album': 'My Album', 'edit-year': '1998',
'edit-art-file': null, // signals the file branch via .files below
'edit-modal': null,
};
const sandbox = {
decodeURIComponent, encodeURIComponent, JSON, Promise,
_lastLibSelected: null,
loadLibrary: () => {}, loadFavorites: () => {},
fetch: (url, opts) => { calls.push({ url, opts }); return Promise.resolve({ ok: true }); },
document: {
getElementById: (id) => {
if (id === 'edit-art-file') return { files: null };
if (id === 'edit-modal') return null;
return id in values ? { value: values[id] } : null;
},
querySelector: () => null, // no active screen
body: { contains: () => false },
},
};
const saveEditModal = loadFn('async function saveEditModal', sandbox, 'saveEditModal');
await saveEditModal(encodeURIComponent('Song With Spaces.sloppak'));
const metaCall = calls.find((c) => /\/api\/song\/.+\/meta$/.test(c.url));
assert.ok(metaCall, 'expected a POST to /api/song/<filename>/meta');
const body = JSON.parse(metaCall.opts.body);
assert.equal(body.year, '1998', 'meta POST body must carry the edited year');
assert.deepEqual(
body,
{ title: 'My Title', artist: 'My Artist', album: 'My Album', year: '1998' },
'meta POST body shape',
);
});
// ── Issue: Renaming Metadata Closes Modal (click-drag release on backdrop) ────
test('_editModalShouldClose: backdrop needs mousedown to have started there', () => {
const fn = loadFn('function _editModalShouldClose', {}, '_editModalShouldClose');
const modalEl = { closest: () => null }; // the backdrop element
const innerEl = { closest: () => null }; // a field inside the modal
const cancelBtn = { closest: (s) => (s === '[data-edit-close]' ? { tag: 'button' } : null) };
// Cancel / ✕ always closes, regardless of where the mousedown began.
assert.equal(fn(cancelBtn, modalEl, false), true, 'Cancel/✕ closes');
assert.equal(fn(cancelBtn, modalEl, true), true, 'Cancel/✕ closes (down-on-backdrop irrelevant)');
// Genuine backdrop click: down AND up on the backdrop.
assert.equal(fn(modalEl, modalEl, true), true, 'backdrop down+up closes');
// The reported bug: drag began inside a field (down NOT on backdrop), click
// resolves to the backdrop on release — must NOT close.
assert.equal(fn(modalEl, modalEl, false), false, 'drag-from-field release on backdrop does NOT close');
// A click that lands on inner content never closes via the backdrop path.
assert.equal(fn(innerEl, modalEl, true), false, 'click on inner content does not close');
});