Reorderable columns, a loaded first file, and a spectrogram worth keeping

Drag a heading and the column moves, with a copy of it following the pointer
and the column it came from stepping back. One array of keys — every column,
shown or hidden — backs the table, the Settings list and what is saved, so a
hidden column keeps its neighbour and returns where it was rather than at the
end. A drag ends in a click, and the click is what sorting listens for, so
that click is swallowed.

Opening a folder loads its first file: the read, the peaks, the channel chips
and the waveform, then paused at the start rather than playing. playRow gained
a paused mode instead of a second loader that could drift from it. It cannot
stop a folder opening, and a file that will not decode no longer writes a
playback error over the line saying the folder opened — nobody asked it to
play. The badge follows the transport now, so a paused file stops claiming to
be playing.

A bulk run re-reads every file it touches, but nothing told the transport to
look again: rename the boom across a card and the mixer went on showing the
old name for the very file it had just rewritten.

Settings loses its Done button and the mixer its Close — both apply as you
touch them, and the window's own close is enough. Save Image moves onto the
file name's line; the rule meant to do that had been dead for some time,
overwritten by the shared modal header. The exported spectrogram carries the
folder, the file and the frequency axis, drawn in, because a PNG read
somewhere else has none of that. The viewer is unchanged.

Mixer track names wear the player's pill, the waveform thumbnails give their
grey panel back to the row, and every button is the transport's size.

Build output that had been committed by accident is untracked, and .gitignore
grows the entries for it.

459 checks.
This commit is contained in:
2026-09-04 00:28:31 +02:00
parent 16c3e6e103
commit 6b7c09bacd
8 changed files with 1163 additions and 34 deletions
+499 -4
View File
@@ -639,6 +639,14 @@ function press(el, key, opts) {
await waitFor(() => /Done/i.test(status.textContent), "folder open + parse");
const rowsOf = () => Array.from(doc.querySelectorAll("[data-bwfa-table-body] tr"));
/** A row's cell for one column, found by key so it survives reordering. */
const cellText = (tr, key) => {
const heads = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"));
const at = heads.findIndex((th) => th.getAttribute("data-bwfa-col") === key);
assert(at !== -1, "no " + key + " column in the head");
return tr.children[at].textContent.trim();
};
const headers = () => Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
.map((th) => th.textContent.replace(/[▲▼\s]+$/, "").trim());
const col = (rowIndex, header) => {
@@ -654,10 +662,49 @@ function press(el, key, opts) {
// appears on the first Play and vanishes when the file ends.
const player = doc.querySelector("[data-bwfa-player]");
assert.strictEqual(player.hidden, false, "the player is hidden with a folder open");
assert.strictEqual(doc.querySelector("[data-bwfa-player-playpause]").disabled, true,
"Play is offered with nothing loaded");
assert.strictEqual(doc.querySelector("[data-bwfa-channel-row]").hidden, true,
"the channel hint is up with no channels to mute");
// And it is already holding the folder's first file, ready to play:
// a full table over a transport reading "nothing playing" was a step
// nobody wanted to take before hearing the first take of the day.
assert.strictEqual(doc.querySelector("[data-bwfa-player-playpause]").disabled, false,
"Play is dead, so the first file wasn't loaded");
assert.strictEqual(doc.querySelector("[data-bwfa-player-filename]").textContent.trim(),
cellText(rowsOf()[0], "fileName"),
"the transport is holding something other than the first row");
// Loaded means loaded: the channels are listed and mutable, and the
// waveform is drawn, without a note being played.
assert.strictEqual(doc.querySelector("[data-bwfa-channel-row]").hidden, false,
"no channels to mute, so the file wasn't really loaded");
assert(doc.querySelectorAll("[data-bwfa-channel-row] .bwfa-channel-chip").length > 0,
"the channel row is empty");
assert.notStrictEqual(
doc.querySelector("[data-bwfa-player-duration]").textContent.trim(), "00:00:00",
"no length, so the file was named but not read");
// Precisely: a real transport, holding peaks, standing still at the
// start. Paused rather than idle is what makes Play a resume.
const held = window.BWFA_STATE.playback;
assert(held, "there is no transport, only a label");
assert(held.peaks && held.peaks.min && held.peaks.min.length,
"loaded without the waveform data, so there is nothing to draw");
assert.strictEqual(held.isPlaying, false, "it started playing on its own");
assert.strictEqual(held.offset, 0, "it loaded part way in");
});
await checkAsync("ready is not playing, and Play starts what it is holding", async () => {
// Opening a folder should not make a noise on its own.
assert.strictEqual(engine.state, null,
"opening a folder started playback by itself");
const playpause = doc.querySelector("[data-bwfa-player-playpause]");
const named = doc.querySelector("[data-bwfa-player-filename]").textContent.trim();
click(playpause);
await waitFor(() => engine.state !== null, "Play to start the loaded file", 5000);
assert.strictEqual(doc.querySelector("[data-bwfa-player-filename]").textContent.trim(),
named, "Play started a different file from the one on the transport");
click(playpause);
await waitFor(() => /play/i.test(playpause.textContent), "it to come back to rest", 4000);
// Put the stub back to untouched. The playback checks further down
// wait for the engine to be asked for a file, and would sail past
// that wait on the strength of this click rather than their own.
engine.state = null;
});
check("the folder opens straight into edit mode", () => {
@@ -1280,6 +1327,57 @@ function press(el, key, opts) {
});
});
check("every button in the app is the one size", () => {
// The transport's buttons set it. Everything used to be a size bigger
// than the Play button beneath it, and a button added later inherits
// whatever .btn says — so this reads the whole document rather than a
// list someone has to remember to extend.
//
// Round buttons, the traffic-light close and the chips are out of
// scope by construction: none of them carries .btn, so they cannot
// appear here at all. Asserted below, because that is the assumption
// this check rests on.
const sizeOf = (b) => {
const cs = window.getComputedStyle(b);
return { font: cs.fontSize, pad: cs.padding, height: cs.height };
};
// Round ones are out, by shape rather than by name: the eject button
// wears .btn as well as its own circle, so a list of exempt classes
// would need maintaining and this does not.
const round = (b) => /50%|9999px/.test(window.getComputedStyle(b).borderRadius || "");
const buttons = Array.from(doc.querySelectorAll(".btn")).filter((b) => !round(b));
assert(buttons.length > 20, "only " + buttons.length + " buttons found");
assert(Array.from(doc.querySelectorAll(".btn")).some(round),
"nothing round left in the sweep — the filter is no longer testing anything");
// The Choose beside the export destination is the documented
// exception: it takes the height of the path field it is paired with.
const chooser = doc.querySelector("[data-bwfa-export-choose]");
assert(chooser && chooser.classList.contains("btn"), "no export chooser");
const standard = sizeOf(doc.querySelector("[data-bwfa-player-playpause]"));
assert(standard.font && standard.pad,
"couldn't read the transport button's own size");
const odd = buttons.filter((b) => b !== chooser)
.filter((b) => {
const s = sizeOf(b);
return s.font !== standard.font || s.pad !== standard.pad;
});
assert.strictEqual(odd.length, 0, odd.length + " buttons are a different size, e.g. '" +
(odd[0] && odd[0].className) + "' at " + JSON.stringify(odd[0] && sizeOf(odd[0])) +
" against the transport's " + JSON.stringify(standard));
// The exception stays one exception, and stays the size of its field.
assert.strictEqual(sizeOf(chooser).font, standard.font,
"even the paired button shares the font size");
assert.strictEqual(sizeOf(chooser).height, "38px",
"the chooser no longer matches the field beside it: " + sizeOf(chooser).height);
["bwfa-round-btn", "modal-close", "chip"].forEach((cls) => {
assert.strictEqual(doc.querySelectorAll("." + cls + ".btn").length, 0,
"a ." + cls + " has picked up .btn, so this rule now resizes it");
});
});
check("the file the arrow leads to sits by the buttons, not mid-footer", () => {
// jsdom has no layout, so this reads the cascade instead. The trap is
// specific and it has already bitten once: the framework gives
@@ -1408,6 +1506,26 @@ function press(el, key, opts) {
missed.length + " of " + carrying.length +
" files still say Boom: " + missed.join(", "));
// The transport is holding one of the files that was just rewritten,
// and it drew its track names when the file was loaded. Nothing tells
// it to look again, so it went on showing the old ones over a table
// already showing the new.
const onAir = window.BWFA_STATE.playerRow();
assert(onAir, "nothing on the transport to check");
const trackNames = ((onAir.parsed.ixml && onAir.parsed.ixml.trackList) || [])
.map((t) => String(t.name || "").trim()).filter(Boolean);
const chips = Array.from(doc.querySelectorAll("[data-bwfa-channel-row] .bwfa-channel-chip"))
.map((c) => c.textContent.trim());
trackNames.forEach((name) => assert(chips.indexOf(name) !== -1,
"the player still lists the old names — has " + chips.join(", ") +
", the file now says " + trackNames.join(", ")));
const strips = Array.from(doc.querySelectorAll("[data-bwfa-mixer-strips] .bwfa-mixer-name"))
.map((s) => s.textContent.trim());
if (strips.length) {
trackNames.forEach((name) => assert(strips.indexOf(name) !== -1,
"the mixer still lists the old names: " + strips.join(", ")));
}
// Applying closes the panel, and the checks below expect it open.
click(doc.querySelector("[data-bwfa-bulk-edit-toggle]"));
await waitFor(() => bulkPanel.hidden === false, "the panel to come back", 5000);
@@ -1648,6 +1766,65 @@ function press(el, key, opts) {
assert(/save/i.test(save.textContent), "it doesn't say what it does");
});
check("the exported picture carries the file name and the frequency scale", () => {
// A PNG leaves the app: on its own it is a pretty picture of an
// unknown file at an unknown scale. Recorded rather than rendered —
// there is no canvas here — so text that never reaches the image
// cannot pass for a caption.
const texts = [];
const drawn = [];
const realGetContext = window.HTMLCanvasElement.prototype.getContext;
const realToBlob = window.HTMLCanvasElement.prototype.toBlob;
const plot = doc.querySelector("[data-bwfa-spectro-canvas]");
const plotWas = { width: plot.width, height: plot.height };
let exported = null;
window.HTMLCanvasElement.prototype.getContext = function () {
const base = realGetContext.call(this);
return new Proxy(base, {
get: (target, prop) => {
if (prop === "fillText") return (s) => texts.push(String(s));
if (prop === "drawImage") return (img) => drawn.push(img);
return target[prop];
},
set: () => true,
});
};
window.HTMLCanvasElement.prototype.toBlob = function () { exported = this; };
try {
click(doc.querySelector("[data-bwfa-spectro-save]"));
} finally {
window.HTMLCanvasElement.prototype.getContext = realGetContext;
window.HTMLCanvasElement.prototype.toBlob = realToBlob;
}
assert(exported, "nothing was handed to the save panel");
assert.notStrictEqual(exported, plot,
"it exported the bare plot, so the name and the scale are missing");
assert(exported.width > plotWas.width,
"the image is no wider than the plot, so there is no room for the scale");
assert(drawn.indexOf(plot) !== -1, "the picture itself isn't in the export");
const said = texts.join(" | ");
const named = doc.querySelector("[data-bwfa-spectro-title]").textContent.trim();
assert(said.indexOf(named) !== -1, "the file isn't named in the image: " + said);
assert(said.indexOf(window.BWFA_FOLDER_NAME) !== -1,
"the folder isn't named, so a take number names nothing: " + said);
assert(/\bkHz\b/.test(said), "no frequency scale in the image: " + said);
// The top of the axis is Nyquist, and these are 48k files.
assert(texts.some((s) => /^24(\.0)? kHz$/.test(s.trim())),
"the axis doesn't reach 24 kHz on a 48k file: " + said);
assert(texts.some((s) => /^0 Hz$/.test(s.trim())),
"the axis doesn't start at DC: " + said);
// And the viewer is left exactly as it was: the caption belongs to
// the export, not to the screen.
assert.strictEqual(plot.width, plotWas.width, "the export resized the plot on screen");
assert.strictEqual(plot.height, plotWas.height, "the export resized the plot on screen");
assert.strictEqual(doc.querySelectorAll("[data-bwfa-spectro-scale] span").length, 5,
"the on-screen scale was disturbed");
});
check("it follows the channel chips rather than always summing", () => {
// Solo the boom and you see the boom. Looking at what you are hearing
// is the entire reason to have it in the player.
@@ -1685,6 +1862,26 @@ function press(el, key, opts) {
"the mixer and the player disagree on the track names: " + names.join(", "));
});
check("a track name in the mixer looks like a track name in the player", () => {
// The two places that list the tracks of a file should look like the
// same thing. The mixer's name is a label and the player's is the
// mute button, so it takes the look and not the behaviour.
const chip = doc.querySelector("[data-bwfa-channel-row] .bwfa-channel-chip");
const name = doc.querySelector("[data-bwfa-mixer-strips] .bwfa-mixer-name");
assert(chip && name, "need both a player chip and a mixer name on screen");
const asChip = window.getComputedStyle(chip);
const asName = window.getComputedStyle(name);
["fontSize", "padding", "borderRadius", "backgroundColor",
"borderTopWidth", "borderTopStyle", "borderTopColor"].forEach((prop) => {
assert.strictEqual(asName[prop], asChip[prop],
prop + " differs — mixer has " + JSON.stringify(asName[prop]) +
", player has " + JSON.stringify(asChip[prop]));
});
// Look, not behaviour: it is not offering itself as a button.
assert.notStrictEqual(asName.cursor, "pointer",
"the mixer's label looks clickable, and clicking it does nothing");
});
await checkAsync("pulling a fader down reaches the engine, that track only", async () => {
const fader = doc.querySelector('[data-bwfa-mixer-fader="0"]');
assert(fader, "no fader on the first track");
@@ -2032,6 +2229,292 @@ function press(el, key, opts) {
"the stop", 5000);
});
/* --- dragging a heading reorders the table --- */
// jsdom has no layout, so every getBoundingClientRect is a box of zeros
// and the drag has nothing to aim at. Give the headings a synthetic
// 100px each, on the prototype so it survives the re-render mid-drag,
// and put it back afterwards — the mixer's click-to-seek measures itself
// the same way and would read these boxes as its own.
const realRect = window.Element.prototype.getBoundingClientRect;
function withHeaderGeometry(fn) {
window.Element.prototype.getBoundingClientRect = function () {
if (this.tagName === "TH" && this.hasAttribute("data-bwfa-col")) {
const at = Array.from(this.parentNode.children).indexOf(this);
return { left: at * 100, right: at * 100 + 100, top: 0, bottom: 20,
width: 100, height: 20, x: at * 100, y: 0 };
}
return realRect.call(this);
};
try { return fn(); } finally {
window.Element.prototype.getBoundingClientRect = realRect;
}
}
const headKeys = () => Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
.map((th) => th.getAttribute("data-bwfa-col")).filter(Boolean);
const settingsKeys = () => Array.from(
doc.querySelectorAll("[data-bwfa-columns-menu] .form-switch span"))
.map((s) => s.textContent.trim());
const centreOf = (key) => {
const heads = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"));
const at = heads.findIndex((th) => th.getAttribute("data-bwfa-col") === key);
return at * 100 + 50;
};
function dragHeading(key, ontoKey) {
return withHeaderGeometry(() => {
const th = doc.querySelector('[data-bwfa-col="' + key + '"]');
assert(th, "no heading for " + key);
const at = centreOf(key);
th.dispatchEvent(new window.MouseEvent("mousedown",
{ bubbles: true, clientX: at, button: 0 }));
doc.dispatchEvent(new window.MouseEvent("mousemove",
{ bubbles: true, clientX: centreOf(ontoKey) }));
doc.dispatchEvent(new window.MouseEvent("mouseup", { bubbles: true }));
});
}
check("you can see what you are dragging, and only while you drag it", () => {
const ghost = () => doc.querySelector("[data-bwfa-col-ghost]");
assert(!ghost(), "something is following the pointer before a drag starts");
withHeaderGeometry(() => {
const key = headKeys()[1];
const label = doc.querySelector('[data-bwfa-col="' + key + '"]')
.firstChild.textContent.trim();
doc.querySelector('[data-bwfa-col="' + key + '"]').dispatchEvent(
new window.MouseEvent("mousedown", { bubbles: true, clientX: centreOf(key), button: 0 }));
doc.dispatchEvent(new window.MouseEvent("mousemove",
{ bubbles: true, clientX: centreOf(key) + 30, clientY: 40 }));
const held = ghost();
assert(held, "nothing shows what is being dragged");
assert.strictEqual(held.textContent.trim(), label,
"it names " + held.textContent + " rather than the column being dragged");
assert.strictEqual(held.getAttribute("data-bwfa-col-ghost"), key, "it names the wrong column");
assert.strictEqual(window.getComputedStyle(held).pointerEvents, "none",
"the marker takes the pointer, so the drag can't see the headings under it");
// And the column it came from shows that it is the one in hand.
assert(doc.querySelector('th[data-bwfa-col="' + key + '"]').classList.contains("is-dragging"),
"the heading it came from isn't marked");
doc.dispatchEvent(new window.MouseEvent("mouseup", { bubbles: true }));
assert(!ghost(), "it stayed on screen after the drag ended");
assert.strictEqual(doc.querySelectorAll("th.is-dragging").length, 0,
"a heading is left marked as being dragged");
});
});
check("dragging a heading moves the column, and the row follows it", () => {
const before = headKeys();
const moved = before[0], onto = before[3];
const wasFirstCell = cellText(rowsOf()[0], moved);
dragHeading(moved, onto);
const after = headKeys();
assert.notDeepStrictEqual(after, before, "nothing moved");
assert.strictEqual(after.indexOf(moved), before.indexOf(onto),
"expected " + moved + " to land where " + onto + " was; got " + after.join(", "));
assert.deepStrictEqual(after.slice().sort(), before.slice().sort(),
"a column was lost or duplicated: " + after.join(", "));
// The body has to move with the head, or every value is under the
// wrong heading — which is worse than not reordering at all.
assert.strictEqual(cellText(rowsOf()[0], moved), wasFirstCell,
"the cells didn't follow their heading");
});
await checkAsync("a drag is not also a request to sort", async () => {
// mousedown-move-mouseup on a heading ends in a click, and the click
// is the one the sort listens for. Reordering the table and resorting
// it in the same gesture is two surprises for the price of one.
const key = headKeys()[1];
const sortedBy = () => {
const th = doc.querySelector("[data-bwfa-table-head] th.is-sorted");
return th && th.getAttribute("data-bwfa-sort");
};
const before = sortedBy();
const arrowOf = () => {
const el = doc.querySelector('[data-bwfa-sort="' + before + '"] .bwfa-sort-indicator');
return el && el.textContent;
};
const startedAt = arrowOf();
try {
dragHeading(key, headKeys()[3]);
// The click the browser sends after a drag, which is what gets past
// a naive implementation.
doc.querySelector('[data-bwfa-col="' + key + '"]')
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
assert.strictEqual(sortedBy(), before,
"the drag re-sorted the table by " + sortedBy());
// And a plain click still sorts, or this could be "fixed" by
// breaking sorting altogether. Done on the column that is already
// sorted, and toggled back: the checks further down read the first
// row of the table and would be sorting a different day's work.
assert(before, "nothing is sorted, so there is no direction to flip");
// The suppression lifts on the next tick, which is the tick after the
// browser has delivered the drag's own click. Wait for it, or this
// would be testing the suppression a second time over.
await new Promise((resolve) => setTimeout(resolve, 0));
const arrow = () => doc.querySelector(
'[data-bwfa-sort="' + before + '"] .bwfa-sort-indicator').textContent;
const plainClick = () => doc.querySelector('[data-bwfa-sort="' + before + '"]')
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
const wasArrow = arrow();
plainClick();
assert.notStrictEqual(arrow(), wasArrow, "a plain click stopped sorting");
plainClick();
assert.strictEqual(arrow(), wasArrow, "couldn't put the sort back as it was");
assert.strictEqual(sortedBy(), before, "the sort column moved");
} finally {
// Whatever happened above, hand the table back sorted the way it
// was found. A broken suppression re-sorts it, and the checks
// further down read the first row — they should report their own
// problem, not this one, and not by hanging.
for (let tries = 0; tries < 4; tries++) {
if (sortedBy() === before && arrowOf() === startedAt) break;
doc.querySelector('[data-bwfa-sort="' + before + '"]')
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
}
}
});
check("the waveform column takes the table's own background", () => {
const css = fs.readFileSync(INDEX, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\n/g, " ");
const rules = css.match(/\.bwfa-scope \.bwfa-waveform-thumb \{[^}]*\}/g) || [];
assert(rules.length >= 1, "no rule for the waveform thumbnails");
// The last one wins, and it has to hand the background back.
const last = rules[rules.length - 1];
assert(/background-color:\s*transparent/.test(last),
"the thumbnails still paint their own panel: " + last);
});
check("the order is written down, so it comes back next launch", () => {
const saved = JSON.parse(window.localStorage.getItem("bwfa_column_order_v1"));
assert(Array.isArray(saved), "nothing was saved");
assert.deepStrictEqual(saved.filter((k) => headKeys().indexOf(k) !== -1), headKeys(),
"what was saved isn't the order on screen");
// Hidden columns are in there too, or switching one back on would
// send it to the end of the table.
assert(saved.length > headKeys().length,
"the hidden columns were dropped from the saved order");
});
check("the settings list is in the same order as the table", () => {
click(doc.querySelector("[data-bwfa-columns-open]"));
const labels = settingsKeys();
const heads = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
.filter((th) => th.getAttribute("data-bwfa-col"))
.map((th) => th.firstChild.textContent.trim());
// The list carries hidden columns as well, so the visible headings
// should appear within it in exactly this order.
const positions = heads.map((label) => labels.indexOf(label));
assert(positions.every((p) => p !== -1),
"a heading is missing from the settings list: " + heads.join(", "));
assert.deepStrictEqual(positions.slice().sort((a, b) => a - b), positions,
"settings lists them in a different order from the table: " + labels.join(", "));
click(doc.querySelector("[data-bwfa-columns-close]"));
});
check("a hidden column keeps its place rather than going to the end", () => {
const order = () => JSON.parse(window.localStorage.getItem("bwfa_column_order_v1"));
const hiding = headKeys()[2];
const neighbour = order()[order().indexOf(hiding) - 1];
click(doc.querySelector("[data-bwfa-columns-open]"));
const toggles = Array.from(doc.querySelectorAll("[data-bwfa-columns-menu] .form-switch"));
const label = doc.querySelector('[data-bwfa-col="' + hiding + '"]').firstChild.textContent.trim();
const row = toggles.filter((r) => r.querySelector("span").textContent.trim() === label)[0];
assert(row, "no toggle for " + label);
const box = row.querySelector("input");
box.checked = false;
box.dispatchEvent(new window.Event("change", { bubbles: true }));
assert.strictEqual(headKeys().indexOf(hiding), -1, "it stayed in the table");
// Still in the order, still next to what it was next to.
assert.strictEqual(order()[order().indexOf(hiding) - 1], neighbour,
"hiding it moved it in the order");
box.checked = true;
box.dispatchEvent(new window.Event("change", { bubbles: true }));
assert.strictEqual(order()[order().indexOf(hiding) - 1], neighbour,
"it came back somewhere else");
click(doc.querySelector("[data-bwfa-columns-close]"));
});
check("dragging over a hidden column doesn't disturb it", () => {
// The drag only ever sees what is on screen, but the order it edits
// holds everything — so a hidden column has to keep its neighbour.
const order = () => JSON.parse(window.localStorage.getItem("bwfa_column_order_v1"));
click(doc.querySelector("[data-bwfa-columns-open]"));
const label = doc.querySelector('[data-bwfa-col="folder"]').firstChild.textContent.trim();
const row = Array.from(doc.querySelectorAll("[data-bwfa-columns-menu] .form-switch"))
.filter((r) => r.querySelector("span").textContent.trim() === label)[0];
const box = row.querySelector("input");
box.checked = false;
box.dispatchEvent(new window.Event("change", { bubbles: true }));
click(doc.querySelector("[data-bwfa-columns-close]"));
const before = order();
const visible = headKeys();
dragHeading(visible[0], visible[2]);
const after = order();
assert.deepStrictEqual(after.slice().sort(), before.slice().sort(),
"the hidden column was lost in the drag");
assert(after.indexOf("folder") !== -1, "the hidden column fell out of the order");
});
check("neither settings nor the mixer has a second way out", () => {
// Settings saves as you touch it and the mixer applies as you move a
// fader, so a Done or a Close at the bottom implied there was
// something waiting to be confirmed. The window's own close remains,
// and is now the only one.
const settings = doc.querySelector("[data-bwfa-columns-modal]");
assert(settings, "no settings dialog");
assert(!settings.querySelector(".modal-footer"),
"settings still carries a footer for a button to sit in");
const settingsOuts = settings.querySelectorAll("[data-bwfa-columns-close]");
assert.strictEqual(settingsOuts.length, 1,
"settings offers " + settingsOuts.length + " ways out");
assert(settingsOuts[0].classList.contains("modal-close"),
"the one way out of settings isn't the window close");
const mixer = doc.querySelector("[data-bwfa-mixer]");
const mixerOuts = mixer.querySelectorAll("[data-bwfa-mixer-close]");
assert.strictEqual(mixerOuts.length, 1,
"the mixer offers " + mixerOuts.length + " ways out");
assert(mixerOuts[0].classList.contains("modal-close"),
"the mixer's one way out isn't the window close");
// Its footer stays, because stepping between files lives there.
assert(mixer.querySelector(".modal-footer [data-bwfa-mixer-next]"),
"stepping to the next file went with the button");
});
check("Save Image shares the file name's line", () => {
// jsdom has no layout, so this reads the cascade and the order. The
// button used to share a flex row with the reading underneath, and
// that row carries the gap below the whole heading block — so it
// aligned to the bottom of the reading and sat under the name.
const css = fs.readFileSync(INDEX, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\n/g, " ");
// The last rule for each selector, because that is the one that wins
// and the plugin styles this block too.
const lastRule = (selector) => {
const all = css.match(new RegExp(selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
" \\{[^}]*\\}", "g")) || [];
return all[all.length - 1];
};
const head = lastRule(".bwfa-scope .bwfa-spectro-head");
assert(head && /flex-wrap:\s*wrap/.test(head),
"the heading block can't wrap, so the three parts share one line: " + head);
const note = lastRule(".bwfa-scope .bwfa-spectro-head p");
assert(note && /flex:\s*1 0 100%/.test(note),
"the reading doesn't take a line of its own: " + note);
// And it has to come after the button in the markup, or the button is
// what wraps to the second line instead.
const kids = Array.from(doc.querySelector(".bwfa-spectro-head").children);
const saveAt = kids.findIndex((k) => k.hasAttribute("data-bwfa-spectro-save"));
const noteAt = kids.findIndex((k) => k.hasAttribute("data-bwfa-spectro-note"));
assert(saveAt !== -1 && noteAt !== -1, "the heading block is missing a part");
assert(saveAt < noteAt,
"the button comes after the reading, so it wraps below the name");
});
check("the settings are folded away behind their own headings", () => {
click(doc.querySelector("[data-bwfa-columns-open]"));
const sections = Array.from(doc.querySelectorAll("[data-bwfa-settings]"));
@@ -2356,6 +2839,18 @@ function press(el, key, opts) {
"drop highlight not cleared");
});
check("a new folder puts its own first file on the transport", () => {
// The one that bites: a folder had been played, then another folder
// was opened, and the transport went on naming a file that is no
// longer anywhere in the table.
const named = doc.querySelector("[data-bwfa-player-filename]").textContent.trim();
assert.strictEqual(named, cellText(rowsOf()[0], "fileName"),
"the transport is still holding the previous folder's file: " + named);
assert(/A003_14B_T3/.test(named), "expected the dropped folder's own file, got " + named);
assert.strictEqual(doc.querySelector("[data-bwfa-player-playpause]").disabled, false,
"the new folder's first file isn't ready to play");
});
/* --- an empty folder clears the table rather than lying about it --- */
const emptyFolder = path.join(root, "Empty Card");