/**
* End-to-end test of the native bridge, without a Mac.
*
* The Rust side is re-implemented here in Node — deliberately as a mirror of
* src-tauri/src/main.rs, same headers, same clamping, same return shapes — and
* wired up as window.__TAURI__. Everything above it is the real thing: the real
* bridge, the real analyser, the real parser, real files on disk.
*
* So this proves the contract between the frontend and the commands: that
* opening a folder reaches the table, that a metadata edit lands on disk
* correctly, and that exports get written where the save panel said.
*
* Run: npm i jsdom && node build/test-tauri.js
*/
const fs = require("fs");
const os = require("os");
const path = require("path");
const assert = require("assert");
const { JSDOM, VirtualConsole } = require("jsdom");
const { build } = require("./make-sample.js");
// The engine's reading, mirrored: peaks and probes for the playback stub.
const wav = require("./wav-convert.js");
const INDEX = path.join(__dirname, "..", "mac-app", "dist", "index.html");
// BWF_TINY_CHUNKS=1 shrinks the bridge's transfer limits so every read and
// write takes the multi-chunk path, against files small enough to test with.
const TINY = process.env.BWF_TINY_CHUNKS === "1";
const LIMITS = TINY ? { largeRead: 4096, readChunk: 1024, writeChunk: 512 } : null;
/* ------------------------------------------------------------------ */
/* Scratch folder of recordings */
/* ------------------------------------------------------------------ */
const root = fs.mkdtempSync(path.join(os.tmpdir(), "bwf-card-"));
fs.mkdirSync(path.join(root, "MixPre", "Day14"), { recursive: true });
const samples = [
["MixPre/A001_12A_T1.wav", { scene: "12A", take: 1 }],
["MixPre/A002_12A_T2.wav", { scene: "12A", take: 2, note: "plane overhead" }],
["MixPre/Day14/A003_14B_T3.wav", { scene: "14B", take: 3, circled: true, tcSamples: 11 * 3600 * 48000 }],
];
samples.forEach(([rel, opts]) => fs.writeFileSync(path.join(root, rel), build(opts)));
fs.writeFileSync(path.join(root, "MixPre", "notes.txt"), "not a recording");
const cardFolder = path.join(root, "MixPre");
const csvTarget = path.join(root, "report.csv");
const csvTrimmedTarget = path.join(root, "report-trimmed.csv");
const pdfTarget = path.join(root, "report.pdf");
/* ------------------------------------------------------------------ */
/* The Rust commands, mirrored */
/* ------------------------------------------------------------------ */
const RECORDING = /\.(wav|bwf|broadcastwave)$/i;
let dialogQueue = [];
const ipcLog = [];
const savePanelDefaults = [];
function describe(filePath, relativePath) {
const stat = fs.statSync(filePath);
return {
path: filePath,
relativePath,
name: path.basename(filePath),
size: stat.size,
lastModified: Math.floor(stat.mtimeMs),
};
}
function walk(dir, prefix, out) {
for (const name of fs.readdirSync(dir).sort()) {
if (name.startsWith(".")) continue;
const full = path.join(dir, name);
const rel = prefix ? prefix + "/" + name : name;
const stat = fs.statSync(full);
if (stat.isDirectory()) walk(full, rel, out);
else if (RECORDING.test(name)) out.push(describe(full, rel));
}
}
function hexDecode(hex) {
return Buffer.from(hex, "hex").toString("utf8");
}
function commands(realm) {
// Raw command responses reach the webview as ArrayBuffers, and they have to
// be built in the page's realm or cross-realm instanceof checks lie.
const toRealmBuffer = (buffer) => {
const view = new realm.Uint8Array(buffer.length);
view.set(buffer);
return view.buffer;
};
return {
"plugin:dialog|open": () => {
const next = dialogQueue.shift();
return Promise.resolve(next === undefined ? null : next);
},
"plugin:dialog|save": (payload) => {
// What the panel was offered as a default filename is what a user
// sees first, so it's worth asserting on.
savePanelDefaults.push((payload && payload.options && payload.options.defaultPath) || "");
const next = dialogQueue.shift();
return Promise.resolve(next === undefined ? null : next);
},
bwf_scan: ({ paths }) => {
const out = [];
for (const p of paths) {
const stat = fs.statSync(p);
if (stat.isDirectory()) walk(p, path.basename(p), out);
else if (RECORDING.test(p)) out.push(describe(p, path.basename(p)));
}
return Promise.resolve(out);
},
bwf_list_dir: ({ path: dir }) =>
Promise.resolve(
fs.readdirSync(dir).sort()
.filter((name) => !name.startsWith("."))
.map((name) => ({
name,
path: path.join(dir, name),
kind: fs.statSync(path.join(dir, name)).isDirectory() ? "directory" : "file",
}))
),
bwf_stat: ({ path: p }) => Promise.resolve(describe(p, path.basename(p))),
bwf_read_range: ({ path: p, offset, length }) => {
const size = fs.statSync(p).size;
if (offset >= size || length === 0) return Promise.resolve(toRealmBuffer(Buffer.alloc(0)));
const take = Math.min(length, size - offset);
const fd = fs.openSync(p, "r");
const buffer = Buffer.alloc(take);
fs.readSync(fd, buffer, 0, take, offset);
fs.closeSync(fd);
return Promise.resolve(toRealmBuffer(buffer));
},
bwf_read_all: ({ path: p }) => Promise.resolve(toRealmBuffer(fs.readFileSync(p))),
};
}
const engine = makePlayer(emitTauri);
function mockInvoke(realm) {
const table = Object.assign({}, commands(realm), engine.commands);
return function invoke(command, payload, options) {
ipcLog.push(command);
if (command === "bwf_write") {
const headers = (options && options.headers) || {};
const target = hexDecode(headers["x-bwf-path"]);
const position = parseInt(headers["x-bwf-position"] || "0", 10);
const truncate = headers["x-bwf-truncate"] === "1";
const create = headers["x-bwf-create"] === "1";
const bytes = Buffer.from(payload.buffer ? new Uint8Array(payload) : payload);
if (!fs.existsSync(target)) {
if (!create) return Promise.reject(new Error(target + ": No such file"));
fs.writeFileSync(target, Buffer.alloc(0));
}
const fd = fs.openSync(target, "r+");
fs.writeSync(fd, bytes, 0, bytes.length, position);
if (truncate) fs.ftruncateSync(fd, position + bytes.length);
fs.closeSync(fd);
return Promise.resolve(bytes.length);
}
const handler = table[command];
if (!handler) return Promise.reject(new Error("unknown command " + command));
return handler(payload || {});
};
}
// Tauri's event system, enough of it to deliver a window drag-drop.
/* ------------------------------------------------------------------ */
/* The playback engine, as far as the page can tell */
/* ------------------------------------------------------------------ */
/**
* Playback is Rust now, so the harness answers for it. The real engine owns a
* cpal stream and cannot run here, but everything the page does with it is
* commands out and one event back, and both of those are testable.
*/
function makePlayer(emit) {
const player = { state: null, calls: [] };
player.commands = {
bwf_peaks: (p) => {
const read = wav.peaks(p.path, p.buckets);
return Promise.resolve({
min: Array.from(read.min),
max: Array.from(read.max),
frames: read.frames,
sampleRate: read.sampleRate,
channels: read.channels,
seconds: read.seconds,
});
},
bwf_spectrogram: (p) => {
const picture = wav.spectrogram(p.path, p.columns, p.window, p.gains);
player.calls.push("spectrogram");
player.lastSpectro = p;
return Promise.resolve({
columns: picture.columns,
bins: picture.bins,
seconds: picture.seconds,
sampleRate: picture.sampleRate,
cells: Array.from(picture.cells),
});
},
bwf_play: (p) => {
player.calls.push("play");
const read = wav.probe(p.path, false);
player.state = {
path: p.path,
seconds: p.offset || 0,
duration: read.frames / read.sampleRate,
channels: read.channels,
sampleRate: read.sampleRate,
gains: (p.gains || []).slice(),
playing: true,
};
player.report({});
return Promise.resolve();
},
bwf_pause: () => {
player.calls.push("pause");
if (player.state) player.state.playing = false;
player.report({});
return Promise.resolve();
},
bwf_resume: () => {
player.calls.push("resume");
if (player.state) player.state.playing = true;
player.report({});
return Promise.resolve();
},
bwf_stop: () => {
player.calls.push("stop");
player.state = null;
return Promise.resolve();
},
bwf_seek: (p) => {
player.calls.push("seek");
if (player.state) player.state.seconds = p.seconds;
player.report({});
return Promise.resolve();
},
bwf_gains: (p) => {
player.calls.push("gains");
if (player.state) player.state.gains = (p.gains || []).slice();
player.report({});
return Promise.resolve();
},
};
/** One status message, the shape convert's Status serialises to. */
player.report = (extra) => {
const at = player.state;
emit("bwf://playback", Object.assign({
playing: !!(at && at.playing),
seconds: at ? at.seconds : 0,
duration: at ? at.duration : 0,
channels: at ? at.channels : 0,
sampleRate: at ? at.sampleRate : 0,
ended: false,
reopened: false,
// Peak per channel since the last report, pre-fader, exactly as
// the engine's status carries it.
levels: at ? (player.levels || new Array(at.channels).fill(0)) : [],
error: null,
}, extra || {}));
};
/** Moves the transport, the way the engine's tick does. */
player.advance = (seconds) => {
if (!player.state) return;
player.state.seconds = Math.min(player.state.duration, player.state.seconds + seconds);
player.report({});
};
player.finish = () => {
if (!player.state) return;
player.report({ seconds: player.state.duration, ended: true });
player.state = null;
};
/** The output went away and was rebuilt underneath a playing file. */
player.reopen = () => player.report({ reopened: true });
return player;
}
const listeners = new Map();
function emitTauri(name, payload) {
(listeners.get(name) || []).forEach((fn) => fn({ event: name, payload }));
}
/* ------------------------------------------------------------------ */
/* Page */
/* ------------------------------------------------------------------ */
const consoleErrors = [];
const virtualConsole = new VirtualConsole();
virtualConsole.on("jsdomError", (e) => consoleErrors.push("jsdomError: " + e.message));
virtualConsole.on("error", (...a) => consoleErrors.push("console.error: " + a.join(" ")));
/**
* A stand-in for Web Audio, with a handle on the two things that go wrong in a
* WKWebView: a context that gets interrupted, and one that won't come back.
*/
const audio = {
created: 0,
resumes: 0,
closed: 0,
starts: 0,
refuseResume: false,
rate: 48000,
last: null,
attach(win) {
const log = this;
class Node {
connect() { return this; }
disconnect() {}
}
class Gain extends Node {
constructor() {
super();
this.gain = { value: 1, setValueAtTime() {}, cancelScheduledValues() {} };
}
}
class Source extends Node {
start() { log.starts++; }
stop() {}
}
const buffer = {
numberOfChannels: 2,
duration: 1,
length: 48000,
sampleRate: 48000,
getChannelData: () => new Float32Array(2048),
};
class Context {
constructor() {
log.created++;
log.last = this;
this.sampleRate = log.rate;
this.state = "running";
this.destination = new Node();
this.listeners = [];
this.frozen = false;
this.born = Date.now();
}
/** Real contexts advance their clock while they render. */
get currentTime() {
if (this.frozen) return this.frozenAt;
return (Date.now() - this.born) / 1000;
}
/** An output that has gone away: still "running", clock stopped. */
freeze() {
this.frozenAt = this.currentTime;
this.frozen = true;
}
addEventListener(type, fn) { if (type === "statechange") this.listeners.push(fn); }
fire() { this.listeners.slice().forEach((fn) => fn()); }
/** What macOS taking the audio session away looks like. */
interrupt() { this.state = "interrupted"; this.fire(); }
resume() {
log.resumes++;
if (!log.refuseResume) {
this.state = "running";
this.fire();
}
return Promise.resolve();
}
close() { log.closed++; this.state = "closed"; return Promise.resolve(); }
createBufferSource() { return new Source(); }
createGain() { return new Gain(); }
createChannelSplitter() { return new Node(); }
decodeAudioData() { return Promise.resolve(buffer); }
}
win.AudioContext = Context;
win.webkitAudioContext = Context;
},
};
const css = fs.readFileSync(INDEX, "utf8");
const dom = new JSDOM(fs.readFileSync(INDEX, "utf8"), {
runScripts: "dangerously",
// jsdom treats a custom scheme as an opaque origin and then
// refuses localStorage, which would leave every persistence path
// untested. Tauri itself serves the app from this origin on Windows and
// from tauri://localhost on macOS; either way the app code is the same.
url: "http://tauri.localhost/",
pretendToBeVisual: true,
virtualConsole,
beforeParse(win) {
// jsdom has no Web Audio, and the playback fixes are about what the app
// does when the context misbehaves — so it gets a fake one that can be
// made to misbehave on purpose.
audio.attach(win);
const ctxStub = new Proxy({}, {
get: (target, prop) => {
if (prop === "canvas") return null;
if (prop === "createLinearGradient") return () => ({ addColorStop() {} });
if (prop === "getImageData") return () => ({ data: new Uint8ClampedArray(4) });
// The spectrogram paints through these, so they have to be
// real enough to write into.
if (prop === "createImageData") {
return (w, h) => ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4) });
}
return typeof target[prop] === "undefined" ? () => {} : target[prop];
},
set: () => true,
});
win.HTMLCanvasElement.prototype.getContext = () => ctxStub;
// jsdom implements IntersectionObserver but never fires it, nothing
// being laid out. Removing it puts the app on its own no-observer
// path, which draws every thumbnail immediately — otherwise the
// table's waveforms cannot be tested here at all.
delete win.IntersectionObserver;
win.URL.createObjectURL = () => "blob:mock-" + Math.random().toString(36).slice(2);
win.URL.revokeObjectURL = () => {};
if (LIMITS) win.BWFA_BRIDGE_LIMITS = LIMITS;
win.__TAURI__ = {
core: { invoke: mockInvoke(win) },
event: {
listen: (name, handler) => {
listeners.set(name, (listeners.get(name) || []).concat(handler));
return Promise.resolve(() => {});
},
},
};
},
});
const { window } = dom;
function waitFor(fn, label, timeout = 20000) {
return new Promise((resolve, reject) => {
const started = Date.now();
(function poll() {
let value;
try {
value = fn();
} catch (e) {
return reject(e);
}
if (value) return resolve(value);
if (Date.now() - started > timeout) return reject(new Error("timed out waiting for " + label));
setTimeout(poll, 40);
})();
});
}
const results = [];
function check(name, fn) {
try {
fn();
results.push(["PASS", name]);
} catch (e) {
results.push(["FAIL", name + " — " + e.message]);
}
}
/** A check that has to wait for something: the harness doesn't await `check`,
* so an async body handed to it would pass no matter what it asserted. */
async function checkAsync(name, fn) {
try {
await fn();
results.push(["PASS", name]);
} catch (e) {
results.push(["FAIL", name + " — " + e.message]);
}
}
function click(el) {
el.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
}
/** Returns whether the key was swallowed, which is half of what's being asked. */
function press(el, key, opts) {
const e = new window.KeyboardEvent("keydown",
Object.assign({ key, bubbles: true, cancelable: true }, opts || {}));
el.dispatchEvent(e);
return e.defaultPrevented;
}
(async () => {
await new Promise((r) => window.addEventListener("load", r));
const doc = window.document;
/* --- the shimmed browser APIs the app looks for --- */
check("nothing but the panel is on screen before a folder is opened", () => {
// `[hidden] { display: none }` is a UA rule, so any author rule that
// sets display beats it — the plugin sets display:flex on the player
// and the shell does the same for the results panel, which left the
// toolbar, an empty table and the transport all visible at launch.
["[data-bwfa-results]", "[data-bwfa-player]", "[data-bwfa-progress-wrap]",
"[data-bwfa-editing-note]", "[data-bwfa-bulk-edit-panel]"].forEach((selector) => {
const el = doc.querySelector(selector);
assert(el.hasAttribute("hidden"), selector + " isn't marked hidden");
assert.strictEqual(window.getComputedStyle(el).display, "none",
selector + " is marked hidden but still displayed");
});
});
check("the launch screen is just the one panel", () => {
const app = doc.querySelector("[data-bwfa-app]");
assert.strictEqual(app.classList.contains("has-folder"), false);
assert.strictEqual(window.getComputedStyle(doc.querySelector("[data-bwfa-status]")).display,
"none", "the status line shows before there's anything to report");
assert.strictEqual(window.getComputedStyle(doc.querySelector("[data-bwfa-empty]")).display,
"none", "the empty-state placeholder is showing next to the invitation");
assert.strictEqual(window.getComputedStyle(app).justifyContent, "center",
"the panel isn't centred in the window");
});
check("showDirectoryPicker is provided", () =>
assert.strictEqual(typeof window.showDirectoryPicker, "function"));
check("FileSystemFileHandle can create writables", () =>
assert.strictEqual(typeof window.FileSystemFileHandle.prototype.createWritable, "function"));
/* --- the app is editor-only now --- */
check("editing is the entry point, read-only selection is gone", () => {
const entry = doc.querySelector("[data-bwfa-edit-entry]");
assert.strictEqual(entry.hidden, false, "edit entry not revealed");
assert(/open folder/i.test(entry.textContent), "button not relabelled: " + entry.textContent.trim().slice(0, 60));
const dropzone = doc.querySelector("[data-bwfa-dropzone]");
assert(dropzone, "dropzone markup must stay — the app wires listeners to it");
assert.strictEqual(window.getComputedStyle(dropzone).display, "none", "dropzone is still visible");
assert.strictEqual(
window.getComputedStyle(doc.querySelector(".bwfa-edit-divider")).display, "none",
"the 'or' divider is still visible");
});
check("no page footer, no in-app title", () => {
assert.strictEqual(doc.querySelector(".page-foot"), null, "footer still in the markup");
assert.strictEqual(window.getComputedStyle(doc.querySelector(".bwfa-header")).display, "none",
"the title and privacy note are still showing");
});
check("detail metadata stays two pairs to a row", () => {
// dt and dd are separate grid items, so an odd number of columns
// splits the pairs and every other row reads inside out. Four tracks
// (label, value, label, value) is the only arrangement that can't.
const probe = doc.createElement("dl");
probe.className = "bwfa-meta-grid";
doc.querySelector("[data-bwfa-app]").appendChild(probe);
const style = window.getComputedStyle(probe);
assert.strictEqual(style.display, "grid");
assert.strictEqual((style.gridTemplateColumns.match(/minmax/g) || []).length, 4,
"expected 4 tracks, got: " + style.gridTemplateColumns);
probe.remove();
});
check("the body fills the window, not a 960px column", () => {
// The plugin centres itself inside --container-lg, which leaves dead
// space either side once the window is wider than that.
const style = window.getComputedStyle(doc.querySelector(".bwfa-app"));
assert.strictEqual(style.maxWidth, "none", "still capped at " + style.maxWidth);
assert.strictEqual(style.width, "100%", "width is " + style.width);
});
check("every modal is built to the same four rules", () => {
// These grew one at a time and it showed: three had a rule under the
// heading and three didn't, the titles came in three sizes and the
// widths ran from 660 to 1120. One width scale, one header, one body
// padding, one footer — and a modal picks its width deliberately
// rather than inheriting whatever it was born with.
const shipped = fs.readFileSync(INDEX, "utf8").replace(/\n/g, " ");
assert(/--modal-width:\s*\d+px/.test(shipped), "there is no one width to set");
assert(/--modal-pad:\s*\d+px/.test(shipped), "there is no one padding to set");
// Read off the stylesheet, not off the DOM: a per-dialog rule with
// higher specificity beats the shared one silently, which is how
// four of six dialogs never used --modal-width at all while every
// DOM-level check said they were fine.
const ruled = shipped.match(/\}[^{}]*?-dialog[^{}]*?\{[^}]*\}/g) || [];
ruled.forEach((block) => {
const selector = block.slice(1, block.indexOf("{")).trim();
const body = block.slice(block.indexOf("{"));
if (/form-switch|columns-menu/.test(selector)) {
return; // controls inside a dialog, not the dialog
}
if (/(^|[^-])(max-)?width\s*:/.test(body) &&
!/--modal-width/.test(body) && !/width:\s*100%/.test(body)) {
assert.fail(selector + " sets its own width: " + body.slice(0, 80));
}
});
// The heading and the buttons stay put and the middle scrolls: a
// dialog that scrolls whole makes you hunt for the way out, and one
// that doesn't scroll loses its bottom half, which is what the
// Sound Report did with 26 fields in it.
assert(/\.bwfa-scope \.modal-dialog \{[^}]*max-height:\s*88vh/.test(shipped),
"a modal can grow past the window");
const scrolls = /\.bwfa-scope \.modal-body,[^{]*\{[^}]*overflow-y:\s*auto/;
assert(scrolls.test(shipped), "nothing inside a modal scrolls");
// The title rules must reach a title however it is nested. Two of
// the six wrap theirs in a group so it can sit beside the close
// button, and a direct-child selector missed exactly those two.
doc.querySelectorAll(".modal-header, .bwfa-bulk-edit-header, .bwfa-spectro-head")
.forEach((header) => {
const title = header.querySelector("h3, h4, .modal-title");
if (!title) {
return;
}
assert.strictEqual(title.parentNode === header ||
title.closest(".modal-header, .bwfa-bulk-edit-header, .bwfa-spectro-head") === header,
true, "a title escaped its header");
});
const titleRule = (shipped.match(/[^{}]*\.modal-title[^{}]*\{[^}]*font-size[^}]*\}/g) || []);
titleRule.forEach((rule) => {
assert(!/>\s*(h3|h4|\.modal-title)/.test(rule),
"a title rule uses a direct child and will miss the wrapped ones: " +
rule.slice(0, 90));
});
// Every heading block is ruled off, every footer is ruled off.
const rule = /\.bwfa-scope \.modal-header,[^{]*\{[^}]*border-bottom:[^}]*\}/;
assert(rule.test(shipped), "the headers aren't ruled off together");
const footer = /\.bwfa-scope \.modal-footer,[^{]*\{[^}]*border-top:[^}]*\}/;
assert(footer.test(shipped), "the footers aren't ruled off together");
});
/* --- opening a folder, read-write, through the native panel --- */
dialogQueue = [cardFolder];
click(doc.querySelector("[data-bwfa-edit-folder]"));
const status = doc.querySelector("[data-bwfa-status]");
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) => {
const cells = Array.from(rowsOf()[rowIndex].querySelectorAll("td")).map((td) => td.textContent.trim());
return cells[headers().indexOf(header)];
};
check("the results panel appears once there's something to show", () => {
const results = doc.querySelector("[data-bwfa-results]");
assert.strictEqual(results.hasAttribute("hidden"), false, "results still hidden");
assert.notStrictEqual(window.getComputedStyle(results).display, "none");
// The transport is part of the panel now rather than something that
// 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");
// 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", () => {
assert.strictEqual(rowsOf().length, 3, "got " + rowsOf().length + " rows");
assert.strictEqual(doc.querySelector("[data-bwfa-editing-note]").hidden, false, "not flagged as editing");
assert.strictEqual(doc.querySelector("[data-bwfa-bulk-edit-toggle]").hidden, false, "no bulk edit");
});
check("what a job is doing sits on the button row, not a line of its own", () => {
// As a line below the buttons it only existed while a job ran, so the
// modal grew a line the moment you pressed the button and shrank when
// it finished, under the cursor. Beside the buttons it cannot change
// the height at all.
const actions = doc.querySelector("[data-bwfa-bulk-edit-panel] .bwfa-bulk-edit-actions");
assert(actions, "no actions row in the bulk panel");
assert(actions.querySelector("[data-bwfa-bulk-edit-progress]"),
"the progress text is still outside the button row");
const exportActions = doc.querySelector("[data-bwfa-export-panel] .bwfa-bulk-edit-actions");
assert(exportActions.querySelector("[data-bwfa-export-progress]"),
"the export progress text is still outside the button row");
// And a long message is cut off rather than wrapping onto a second line.
const shipped = fs.readFileSync(INDEX, "utf8").replace(/\n/g, " ");
assert(/bwfa-bulk-edit-progress[^{]*\{[^}]*text-overflow:\s*ellipsis/.test(shipped),
"a long message would still wrap and change the height");
});
check("nothing in the page reads a decoded buffer any more", () => {
// The player's waveform and the row thumbnails came from two different
// places, both reading an AudioBuffer. Only one got moved over the
// first time, so the big waveform drew and the little ones stayed
// blank. There is no decoded buffer in this build at all now, so the
// honest check is that nothing asks one for its samples.
const shipped = fs.readFileSync(INDEX, "utf8");
// Only the mention in a comment survives, which is why this counts
// calls rather than occurrences.
const calls = shipped.split("\n")
.filter((line) => /getChannelData\s*\(/.test(line) && !/^\s*\*/.test(line)).length;
assert.strictEqual(calls, 0,
"the page still calls getChannelData in " + calls + " place(s)");
});
check("Play and Details sit together in the first two columns", () => {
const cells = Array.from(rowsOf()[0].children);
const play = cells[0].querySelector("[data-bwfa-play-btn]");
const details = Array.from(cells[1].querySelectorAll("button"))
.find((b) => /^(details|edit)$/i.test(b.textContent.trim()));
assert(play, "no play button in the first cell");
assert(details, "Details is not in the second cell: " + cells[1].textContent.trim());
// Two action columns, then one header per visible data column.
const headerCount = doc.querySelectorAll("[data-bwfa-table-head] th").length;
assert.strictEqual(headerCount, cells.length, "head and body columns disagree");
});
check("metadata read through ranged IPC reads", () => {
const scenes = rowsOf().map((_, i) => col(i, "Scene")).sort();
assert.deepStrictEqual(scenes, ["12A", "12A", "14B"], scenes.join(","));
const tcs = rowsOf().map((_, i) => col(i, "Start TC"));
assert(tcs.includes("10:00:00:00") && tcs.includes("11:00:00:00"), tcs.join(" | "));
});
check("only headers were read, not whole files", () => {
assert.strictEqual(ipcLog.filter((c) => c === "bwf_read_all").length, 0,
"something pulled an entire file in just to read metadata");
assert(ipcLog.filter((c) => c === "bwf_read_range").length >= 3, "no ranged reads happened");
});
if (TINY) {
check("large reads are split into ranges", () => {
// With the threshold at 4 KB, any whole-file read has to come back
// as many small ranges rather than one bwf_read_all.
assert.strictEqual(ipcLog.filter((c) => c === "bwf_read_all").length, 0);
});
}
check("the folder bar collapses to its name once a folder is open", () => {
const entry = doc.querySelector("[data-bwfa-edit-entry]");
assert(doc.querySelector("[data-bwfa-app]").classList.contains("has-folder"),
"the app is still in its launch state");
assert.notStrictEqual(window.getComputedStyle(status).display, "none",
"the status line stayed hidden after opening a folder");
const label = doc.querySelector("[data-bwfa-current-folder]");
assert(entry.classList.contains("is-folder-open"), "the invitation panel is still expanded");
assert.strictEqual(label.hidden, false, "the folder name isn't shown");
assert.strictEqual(label.textContent, "MixPre", "wrong folder name: " + label.textContent);
assert.strictEqual(window.getComputedStyle(
entry.querySelector(".bwfa-dropzone-subnote")).display, "none",
"the explanatory note is still taking up room");
assert(/change folder/i.test(doc.querySelector("[data-bwfa-edit-folder]").textContent),
"the button still invites you to open a first folder");
});
check("state accents are gone, playing rows go green", () => {
const css = fs.readFileSync(INDEX, "utf8");
// The plugin marks circled and playing rows with coloured slivers on
// the first cell, and rules the header in blue.
const goldSliver = css.lastIndexOf("inset 3px 0 0 var(--color-warning)");
const override = css.lastIndexOf("box-shadow: none");
assert(override > goldSliver, "the circled-take sliver still wins the cascade");
assert(/tr\.bwfa-row-playing td[\s\S]{0,400}background-color: #dff5e4/.test(css),
"no green background for the playing row");
});
check("play and pause are the same width", () => {
const play = rowsOf()[0].querySelector("[data-bwfa-play-btn]");
const width = window.getComputedStyle(play).minWidth;
assert(parseFloat(width) >= 60, "no fixed width, so the row shifts on play: " + width);
});
check("text selection is off outside form fields", () => {
const app = doc.querySelector("[data-bwfa-app]");
assert.strictEqual(window.getComputedStyle(app).userSelect, "none",
"the table is still selectable");
assert.strictEqual(window.getComputedStyle(doc.querySelector("[data-bwfa-search]")).userSelect,
"text", "the search field lost selection, which makes editing miserable");
});
check("the status line has room to breathe", () => {
// The plugin spaced this with the results panel's top margin, which the
// flex layout had to zero.
const gap = parseFloat(window.getComputedStyle(status).marginBottom);
assert(gap >= 10, "only " + gap + "px between the status and the toolbar");
});
const shellCss = Array.from(doc.querySelectorAll("style")).map((el) => el.textContent).join("\n")
.slice(0);
check("the sticky header can actually stick in WebKit", () => {
// Rows were painting over the header. Two framework rules are enough to
// cause that in Safari on their own, and both were in play: collapsed
// borders (which belong to the table grid, not the cells, and have
// never painted correctly under a sticky header) and touch scrolling
// on the container (a separate compositing layer, notorious for stale
// pixels and sticky elements that scroll away).
const table = doc.querySelector(".bwfa-table");
const style = window.getComputedStyle(table);
assert.strictEqual(style.borderCollapse, "separate",
"collapsed borders are back, which breaks the sticky header in Safari");
assert.strictEqual(style.borderSpacing, "0px",
"separate borders without zero spacing would gap every cell");
// Every header cell, not just the first. The plugin gives sortable
// headers `position: relative` through a selector that outranks a
// plain `thead th`, so checking only cell one — an action column,
// which isn't sortable — passed while fourteen of seventeen scrolled
// away. That's how this shipped broken twice.
const headers = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"));
assert(headers.length > 10, "expected a full header row, got " + headers.length);
const notSticky = headers.filter((th) => window.getComputedStyle(th).position !== "sticky");
assert.strictEqual(notSticky.length, 0,
notSticky.length + " of " + headers.length + " headers aren't sticky: " +
notSticky.map((th) => th.textContent.replace(/[▲▼]/g, "").trim() || "(actions)").join(", "));
const sortable = headers.filter((th) => th.hasAttribute("data-bwfa-sort"));
assert(sortable.length > 5, "no sortable headers to check against");
headers.forEach((th) => {
const head = window.getComputedStyle(th);
assert(parseInt(head.zIndex, 10) >= 3, "header z-index is " + head.zIndex);
// The declaration rather than the computed colour: it's a custom
// property now, so it turns over with the theme, and jsdom won't
// substitute one into a computed value.
assert(/background: var\(--color-bg\)/.test(shellCss),
"the sticky header has no background, so rows show through it");
});
// The sort arrow is absolutely positioned inside its cell; sticky is a
// positioned value, so it still has something to anchor to.
const arrow = doc.querySelector(".bwfa-sort-indicator");
assert(arrow, "no sort indicator rendered");
assert.strictEqual(window.getComputedStyle(arrow).position, "absolute");
const css = fs.readFileSync(INDEX, "utf8");
const touch = css.lastIndexOf("-webkit-overflow-scrolling: touch");
const auto = css.lastIndexOf("-webkit-overflow-scrolling: auto");
assert(auto > touch, "touch scrolling still wins the cascade on the scroll container");
});
check("the table is the only scrolling region", () => {
// The player has to stay visible while a long day scrolls past it.
// Reserving page padding under a fixed footer doesn't achieve that:
// the plugin sets .table-responsive to overflow-y:hidden, so the table
// never scrolls itself, the window does, and the last rows end up
// under the footer regardless. Instead the app is a column as tall as
// the window, the table region is the one scrolling box, and the
// player follows it — so there is no clearance to get wrong.
const style = (el) => window.getComputedStyle(el);
const app = doc.querySelector("[data-bwfa-app]");
const results = doc.querySelector("[data-bwfa-results]");
const scroller = doc.querySelector(".table-responsive");
const player = doc.querySelector("[data-bwfa-player]");
assert.strictEqual(style(doc.body).overflow, "hidden",
"the window still scrolls, so a footer can be scrolled past");
assert.strictEqual(style(app).display, "flex", "the app isn't a column");
assert.strictEqual(style(app).flexDirection, "column");
assert.strictEqual(style(app).height, "100%", "the app doesn't fill the window");
assert.strictEqual(style(results).minHeight, "0px",
"without min-height:0 a flex child refuses to shrink below its content");
assert.strictEqual(style(scroller).overflowY, "auto",
"the table region doesn't scroll: " + style(scroller).overflowY);
// A flex item defaults to min-height: auto, which refuses to shrink
// below its content — that's what stops the region scrolling at all.
// It needs an explicit value; a small floor (see the bulk-edit check)
// serves the same purpose as zero, since the table is always taller.
const floor = style(scroller).minHeight;
assert.notStrictEqual(floor, "auto", "min-height: auto stops the region shrinking to scroll");
assert(parseFloat(floor) < window.innerHeight / 3,
"the floor is too tall to count as shrinkable: " + floor);
assert.strictEqual(style(player).position, "static",
"the player is positioned out of flow, which is what put rows under it");
assert.strictEqual(style(player).order, "99",
"the player isn't ordered after the table");
});
check("the table reports the frame rate", () => {
assert(headers().includes("FPS"), "no FPS column: " + headers().join(" | "));
const rates = rowsOf().map((_, i) => col(i, "FPS"));
assert(rates.every((r) => /25/.test(r)), "FPS column is empty: " + rates.join(","));
});
check("right-click is blocked, except in text fields", () => {
// WKWebView's own menu offers Reload, which would throw away the open
// folder and any unsaved edit.
const onTable = new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true });
doc.querySelector("[data-bwfa-table-body] tr").dispatchEvent(onTable);
assert.strictEqual(onTable.defaultPrevented, true, "the webview menu is still reachable");
const onInput = new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true });
doc.querySelector("[data-bwfa-search]").dispatchEvent(onInput);
assert.strictEqual(onInput.defaultPrevented, false,
"text fields lost Cut/Copy/Paste");
});
check("selection colour is overridden after the framework's", () => {
const css = fs.readFileSync(INDEX, "utf8");
const framework = css.indexOf("background-color: var(--gray-900)");
const override = css.indexOf("background-color: #b4d5fe");
assert(override !== -1, "no selection override in the build");
assert(framework === -1 || override > framework,
"the override lands before the framework rule, so black still wins");
});
check("subfolders keep their relative path", () => {
const folders = rowsOf().map((_, i) => col(i, "Folder"));
assert(folders.some((f) => /Day14/.test(f)), folders.join(" | "));
});
/* --- both exports land where the save panel pointed --- */
dialogQueue = [csvTarget];
click(doc.querySelector("[data-bwfa-export-csv]"));
await waitFor(() => fs.existsSync(csvTarget) && fs.statSync(csvTarget).size > 0, "csv on disk", 8000);
check("the export is offered in the open folder, named after it", () => {
// A bare filename leaves the panel wherever it was last; a full path
// puts it in the folder the report is about, which is where a sound
// report belongs.
// The date is whatever day it is when the export runs, which is not
// necessarily the day the expectation was written — this suite once
// failed at midnight for exactly that reason.
const wanted = new RegExp("^" + cardFolder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
"/MixPre \\d{4}-\\d{2}-\\d{2}\\.csv$");
assert(savePanelDefaults.some((target) => wanted.test(target)),
"expected " + wanted + ", offered: " + JSON.stringify(savePanelDefaults));
});
check("CSV export is written through the save panel", () => {
const bytes = fs.readFileSync(csvTarget);
assert.deepStrictEqual(Array.from(bytes.slice(0, 3)), [0xef, 0xbb, 0xbf], "missing BOM");
const text = bytes.toString("utf8");
assert(/A001_12A_T1\.wav/.test(text) && /A003_14B_T3\.wav/.test(text), "rows missing");
assert(/Boom/.test(text), "track names missing");
});
/* --- the export field picker --- */
click(doc.querySelector("[data-bwfa-export-fields-toggle]"));
const exportModal = doc.querySelector("[data-bwfa-export-modal]");
await waitFor(() => exportModal.hidden === false, "export modal", 5000);
check("every reportable field is offered, all on by default", () => {
const boxes = Array.from(doc.querySelectorAll("[data-bwfa-export-field]"));
assert(boxes.length >= 20, "only " + boxes.length + " fields offered");
assert(boxes.every((b) => b.checked),
"some fields start unticked, so the CSV would silently lose columns");
assert(/\d+ of \d+ fields/.test(doc.querySelector("[data-bwfa-export-count]").textContent),
"no field count shown");
});
check("the picker covers what the table can't show", () => {
const keys = Array.from(doc.querySelectorAll("[data-bwfa-export-field]"))
.map((b) => b.getAttribute("data-bwfa-export-field"));
["trackNames", "umid", "codingHistory", "frameRate", "markers"].forEach((key) =>
assert(keys.includes(key), "missing field: " + key));
});
// Untick two fields and export again.
["trackNames", "codingHistory"].forEach((key) => {
const box = doc.querySelector('[data-bwfa-export-field="' + key + '"]');
box.checked = false;
box.dispatchEvent(new window.Event("change", { bubbles: true }));
});
click(doc.querySelector("[data-bwfa-export-close]"));
dialogQueue = [csvTrimmedTarget];
click(doc.querySelector("[data-bwfa-export-csv]"));
await waitFor(() => fs.existsSync(csvTrimmedTarget) && fs.statSync(csvTrimmedTarget).size > 0,
"trimmed csv", 8000);
check("unticked fields leave the CSV", () => {
const header = fs.readFileSync(csvTrimmedTarget, "utf8").split("\r\n")[0];
assert(!/Track Names/.test(header), "Track Names is still there: " + header);
assert(!/Coding History/.test(header), "Coding History is still there");
assert(/File Name/.test(header) && /Scene/.test(header), "the rest of the header went missing");
});
check("the choice is remembered", () => {
const saved = JSON.parse(window.localStorage.getItem("bwfa_export_fields_v1"));
assert(Array.isArray(saved), "nothing persisted");
assert(!saved.includes("trackNames"), "persisted list still has trackNames");
});
dialogQueue = [pdfTarget];
click(doc.querySelector("[data-bwfa-export-pdf]"));
await waitFor(() => fs.existsSync(pdfTarget) && fs.statSync(pdfTarget).size > 1000, "pdf on disk", 20000);
check("PDF export is written through the save panel too", () => {
const bytes = fs.readFileSync(pdfTarget);
assert.strictEqual(bytes.slice(0, 5).toString("latin1"), "%PDF-", "not a PDF");
assert(bytes.length > 1000, "suspiciously small: " + bytes.length);
});
/* --- editing: change a scene, save, check the bytes --- */
const target = path.join(root, "MixPre", "A001_12A_T1.wav");
const before = fs.readFileSync(target);
const targetRow = rowsOf().find((tr) => /A001_12A_T1/.test(tr.textContent));
assert(targetRow, "test file not in the table");
click(Array.from(targetRow.querySelectorAll("button")).find((b) => /^(details|edit)$/i.test(b.textContent.trim())));
const sceneInput = await waitFor(
() => doc.querySelector('[data-bwfa-edit-field="scene"]'), "edit form", 8000);
check("edit form is offered for a writable file", () => {
assert.strictEqual(sceneInput.value, "12A", "unexpected starting value: " + sceneInput.value);
assert(doc.querySelector('[data-bwfa-edit-field="note"]'), "note field missing");
});
check("every edit control is the same height", () => {
// A native select sizes itself from platform metrics and ignores the
// padding that gives an input its height, so they drift apart unless
// both are told explicitly.
const heightOf = (el) => window.getComputedStyle(el).height;
const text = doc.querySelector('[data-bwfa-edit-field="scene"]');
const number = doc.querySelector('[data-bwfa-edit-field="tcSampleRate"]');
const select = doc.querySelector('[data-bwfa-edit-field="frameRate"]');
assert(text && number && select, "expected text, number and select fields");
assert.strictEqual(heightOf(select), heightOf(text),
"select is " + heightOf(select) + " but text input is " + heightOf(text));
assert.strictEqual(heightOf(number), heightOf(text),
"number is " + heightOf(number) + " but text input is " + heightOf(text));
assert.strictEqual(window.getComputedStyle(doc.querySelector(".bwfa-edit-grid")).alignItems,
"stretch", "grid still bottom-aligns its cells");
});
sceneInput.value = "88Z";
sceneInput.dispatchEvent(new window.Event("input", { bubbles: true }));
const noteInput = doc.querySelector('[data-bwfa-edit-field="note"]');
noteInput.value = "retake — bridge test";
noteInput.dispatchEvent(new window.Event("input", { bubbles: true }));
check("stepping through the card lives in the footer, and names the files", () => {
// In the header the arrows pushed the title right, which made this
// the one modal whose heading didn't start on the shared gutter.
const footer = doc.querySelector(".bwfa-modal-dialog .modal-footer");
assert(footer, "no footer in the detail modal");
assert(footer.querySelector("[data-bwfa-modal-prev]"),
"the previous arrow is still in the header");
assert(footer.querySelector("[data-bwfa-modal-next]"),
"the next arrow is still in the header");
assert(!doc.querySelector(".bwfa-modal-dialog .modal-header [data-bwfa-modal-next]"),
"the header still carries an arrow");
// And each arrow says where it leads.
const prev = footer.querySelector("[data-bwfa-modal-prev-name]");
const next = footer.querySelector("[data-bwfa-modal-next-name]");
assert(prev && next, "the arrows don't name their files");
// From any file on a card of three, at least one neighbour exists;
// which one depends on where the modal was opened.
const named = [prev.textContent, next.textContent].filter((t) => t.length);
assert(named.length, "neither arrow named a file");
named.forEach((name) => {
assert(/\.wav$/i.test(name), "that isn't a filename: " + name);
});
});
const saveBtn = doc.querySelector("[data-bwfa-modal-save]");
check("Save becomes available once something changes", () =>
assert.strictEqual(saveBtn.disabled, false));
await checkAsync("a button pressed from inside a field acts on the first press", () => {
// The real cause of Apply needing two goes: pressing a button blurs
// the field you were typing in, the blur re-renders the panel around
// the button, and with the element under the pointer changed between
// press and release no click is generated at all. The second press
// works because focus has already left. So the focus change is taken
// out of the equation, and this is that: the press must not be
// allowed to move focus.
const typed = doc.querySelector('[data-bwfa-edit-field="scene"]');
typed.focus();
const press = new window.MouseEvent("mousedown", { bubbles: true, cancelable: true });
const landed = doc.querySelector("[data-bwfa-modal-save]").dispatchEvent(press);
assert.strictEqual(landed, false,
"pressing a button still moves focus, so the first press can be lost");
assert.strictEqual(doc.activeElement, typed,
"the field lost focus to the button");
});
check("a disabled button is left entirely alone", () => {
// It has nothing to do, and swallowing the press would stop the
// field underneath ever being blurred by clicking away from it.
const dead = doc.querySelector("[data-bwfa-bulk-edit-apply]");
dead.disabled = true;
const press = new window.MouseEvent("mousedown", { bubbles: true, cancelable: true });
assert.strictEqual(dead.dispatchEvent(press), true,
"a disabled button is swallowing the press");
});
/* --- track names are fields, and they reach the file --- */
const trackFields = doc.querySelectorAll("[data-bwfa-track-name]");
check("every track offers its name as something you can type in", () => {
assert(trackFields.length >= 2, "only " + trackFields.length + " track fields");
assert.strictEqual(trackFields[0].value, "Boom", "the name isn't loaded into the field");
assert.strictEqual(trackFields[0].getAttribute("data-bwfa-track-name"), "1",
"the field isn't tied to a channel");
});
// A radio moving from one actor to another between setups is the whole
// reason this needs editing on the day.
trackFields[1].value = "Lav Marieke";
trackFields[1].dispatchEvent(new window.Event("input", { bubbles: true }));
check("typing a track name is a change worth saving", () =>
assert.strictEqual(saveBtn.disabled, false, "Save didn't notice the rename"));
await checkAsync("the new name is written to the file and reads back", async () => {
click(saveBtn);
await waitFor(() => {
const text = fs.readFileSync(
path.join(root, "MixPre", "A001_12A_T1.wav")).toString("latin1");
return text.indexOf("Lav Marieke") !== -1;
}, "the rename to reach the file", 8000).catch(() => {});
const onDisk = fs.readFileSync(path.join(root, "MixPre", "A001_12A_T1.wav"));
const text = onDisk.toString("latin1");
assert(text.indexOf("Lav Marieke") !== -1,
"the new track name never reached the file");
assert(text.indexOf("Boom") !== -1,
"renaming one track wiped another");
// The channel it belongs to has to survive too, or post gets the
// right names against the wrong inputs.
const list = text.slice(text.indexOf(""), text.indexOf(""));
assert(/2<\/CHANNEL_INDEX>[\s\S]*?Lav Marieke/.test(list) ||
/Lav Marieke[\s\S]*?2<\/CHANNEL_INDEX>/.test(list),
"the name landed on the wrong channel: " + list.slice(0, 300));
});
// Bulk edit is built from the same field list, in its own markup.
const bulkPanel = doc.querySelector("[data-bwfa-bulk-edit-panel]");
click(doc.querySelector("[data-bwfa-bulk-edit-toggle]"));
await waitFor(() => bulkPanel.hidden === false, "bulk edit panel", 5000);
/* --- renaming a track across the day --- */
check("the day's track names are listed, commonest first, with counts", () => {
// A reading of the card before it is a form: two spellings of the same
// mic, or a name on two takes of three, are visible without looking.
const lines = doc.querySelectorAll("[data-bwfa-bulk-track]");
assert(lines.length >= 2, "only " + lines.length + " names found");
const names = Array.from(lines).map((l) => l.getAttribute("data-bwfa-bulk-track"));
assert(names.indexOf("Boom") !== -1, "found: " + names.join(", "));
const counts = Array.from(lines)
.map((l) => parseInt(l.querySelector(".bwfa-bulk-track-count").textContent, 10));
for (let i = 1; i < counts.length; i++) {
assert(counts[i] <= counts[i - 1], "not sorted by how many files use them");
}
});
check("resting on a control explains it, in one line", () => {
// Applied on first hover, because half these controls are built when
// a panel opens and watching the whole app for them would cost more
// than answering the question when it is asked.
const button = doc.querySelector("[data-bwfa-export-audio]");
assert.strictEqual(button.hasAttribute("title"), false,
"the hint was there before anybody looked");
button.dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
const hint = button.getAttribute("title");
assert(hint && hint.length, "resting on it said nothing");
assert(hint.length < 60, "that is not a short description: " + hint);
});
check("each column heading explains itself, not just how to sort", () => {
// Every heading carries the same attribute, so the hint is keyed on
// its value: what FPS or Tape/Reel actually means is the reason for
// hinting them at all.
const scene = doc.querySelector('[data-bwfa-sort="scene"]');
const rate = doc.querySelector('[data-bwfa-sort="sampleRate"]');
assert(scene && rate, "the headings aren't sortable any more");
scene.dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
rate.dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
assert(/scene/i.test(scene.getAttribute("title")),
"scene reads: " + scene.getAttribute("title"));
assert(scene.getAttribute("title") !== rate.getAttribute("title"),
"every column says the same thing");
});
check("the status line and the transport readouts explain themselves too", () => {
[
"[data-bwfa-status]",
"[data-bwfa-player-badge]",
"[data-bwfa-player-elapsed]",
"[data-bwfa-player-duration]",
].forEach((selector) => {
const el = doc.querySelector(selector);
assert(el, "no " + selector);
el.dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
assert(el.getAttribute("title"), selector + " says nothing on hover");
});
});
await checkAsync("the controls inside the modals explain themselves too", async () => {
// Every control in a panel is built when the panel opens, which is
// exactly the set that got missed the first time round.
click(doc.querySelector("[data-bwfa-export-audio]"));
await waitFor(() => !doc.querySelector("[data-bwfa-export-panel]").hidden,
"the export panel", 5000);
[
"[data-bwfa-export-depth]",
"[data-bwfa-export-channels]",
"[data-bwfa-export-normalize]",
"[data-bwfa-export-collision]",
"[data-bwfa-export-naming]",
"[data-bwfa-export-dest]",
"[data-bwfa-export-choose]",
"[data-bwfa-export-cancel]",
].forEach((selector) => {
const el = doc.querySelector(selector);
assert(el, "no " + selector);
el.dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
assert(el.getAttribute("title"), selector + " says nothing on hover");
});
click(doc.querySelector("[data-bwfa-export-cancel]"));
// Opening export closed bulk edit, which the checks after this expect
// to still be up.
click(doc.querySelector("[data-bwfa-bulk-edit-toggle]"));
await waitFor(() => bulkPanel.hidden === false, "bulk edit back", 5000);
});
check("pointing at a label answers for the field it names", () => {
// The word is what you actually point at; the control is a box next
// to it. A label that says nothing is the common case of "it has no
// hints" even when it does.
const label = doc.querySelector('label[for="bwfa-export-depth"]');
assert(label, "the depth control has no label");
// Cleared first: an earlier hover on the control itself would
// otherwise answer for the label and prove nothing.
doc.querySelector("[data-bwfa-export-depth]").removeAttribute("title");
label.dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
assert(doc.querySelector("[data-bwfa-export-depth]").getAttribute("title"),
"pointing at the label explained nothing");
});
check("every modal closes the way a Mac window closes, and none is missed", () => {
// One rule, one class, so the thing worth checking is that no dialog
// grew its own close control and slipped out from under it.
const dialogs = doc.querySelectorAll(".modal-dialog");
assert(dialogs.length >= 5, "only " + dialogs.length + " dialogs found");
dialogs.forEach((dialog) => {
const closers = dialog.querySelectorAll(".modal-close");
assert.strictEqual(closers.length, 1,
dialog.className + " has " + closers.length + " close buttons");
});
const shipped = fs.readFileSync(INDEX, "utf8").replace(/\n/g, " ");
// The framework styles this class too, so the one that counts is the
// last one in the cascade, not the first one in the file.
const rules = shipped.match(/\.bwfa-scope \.modal-close \{[^}]*\}/g) || [];
assert(rules.length, "the close button has no rule of its own");
const rule = rules[rules.length - 1];
assert(/left:\s*var\(--traffic-inset\)/.test(rule),
"it isn't on the left: " + rule);
assert(/right:\s*auto/.test(rule), "it is still pinned right too");
assert(/border-radius:\s*50%/.test(rule), "it isn't round");
assert(/#ff5f57/.test(rule), "it isn't the system's red");
// The detail modal focuses its close button as it opens, and the
// framework's focus ring drew a black box around the traffic light
// on that one modal only.
assert(/\.modal-close:focus\s*\{[^}]*outline:\s*none/.test(shipped),
"focusing the close button still draws a ring around it");
assert(/width:\s*var\(--traffic-size\)/.test(rule) &&
/height:\s*var\(--traffic-size\)/.test(rule),
"it isn't sized from the one place that sets it: " + rule);
// Both numbers in one place, so nudging them is two edits and not
// a hunt through six rules.
assert(/--traffic-size:\s*\d+px/.test(shipped), "no size to nudge");
assert(/--traffic-inset:\s*\d+px/.test(shipped), "no inset to nudge");
// Nothing later in the cascade may move it, resize it or pad it. The
// sheets used to carry their own rule for exactly that, which is how
// one modal's button ended up a different size from the rest.
const after = shipped.slice(shipped.lastIndexOf(rule) + rule.length);
const meddling = after.match(/\.bwfa-scope [^{}]*\.(modal-close|bwfa-sheet-close)[^{}]*\{[^}]*\}/g) || [];
meddling.forEach((later) => {
assert(!/(^|[^-])(top|left|right|bottom|width|height|padding|border-radius):/.test(later),
"a later rule still shapes the close button: " + later);
});
});
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
// .bwfa-modal-nav `flex: 1 1 auto` for the header it was designed
// for, and inherited into the footer that makes both groups grow to
// fill the row. The forward arrow then floats in the middle of the
// footer, far from Close, and the margin-left: auto meant to park it
// there is silently powerless — there is no free space to absorb.
// Comments out first: this rule explains the trap in prose, and the
// prose names the very value being asserted against.
const shipped = fs.readFileSync(INDEX, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\n/g, " ");
const footerNav = shipped.match(
/\.bwfa-scope \.modal-footer \.bwfa-modal-nav \{[^}]*\}/g) || [];
assert.strictEqual(footerNav.length, 1,
"expected one footer rule for the nav groups, found " + footerNav.length);
const grow = /flex:\s*(\d)/.exec(footerNav[0]);
assert(grow, "the footer rule doesn't set flex at all, so the header's 1 1 auto wins: "
+ footerNav[0]);
assert.strictEqual(grow[1], "0",
"the nav groups still grow, so the forward arrow floats mid-footer: " + footerNav[0]);
const on = shipped.match(
/\.bwfa-scope \.modal-footer \.bwfa-modal-nav-on \{[^}]*\}/);
assert(on && /margin-left:\s*auto/.test(on[0]),
"nothing pushes the forward group over to the buttons: " + (on && on[0]));
});
await checkAsync("clicking the dimmed page closes whatever is over it", async () => {
// A behaviour check, not a check of the handler that provides it:
// the framework already does this for its own modals, and the
// generic one added here covers any that it doesn't. Either way,
// what matters is that the dimmed page is a way out.
click(doc.querySelector("[data-bwfa-report-open]"));
await waitFor(() => !doc.querySelector("[data-bwfa-export-modal]").hidden,
"the sound report", 5000);
// The overlay itself, which is what a click beside the dialog
// actually lands on — not the backdrop element behind it.
click(doc.querySelector("[data-bwfa-export-modal]"));
await waitFor(() => doc.querySelector("[data-bwfa-export-modal]").hidden,
"it to close on a click outside", 5000);
});
check("every dialog can position its own close button", () => {
// Absolute positioning anchors to the nearest positioned ancestor.
// Miss this and the button anchors to the overlay instead, landing
// off the corner of the panel where nobody will ever find it.
const shipped = fs.readFileSync(INDEX, "utf8").replace(/\n/g, " ");
assert(/\.bwfa-scope \.modal-dialog \{[^}]*position:\s*relative/.test(shipped),
"a dialog can't position its own close button");
});
check("the hints come from the same file as every other string", () => {
// So changing the wording is editing one file, not hunting through
// markup and JavaScript for it.
assert(window.bwfaL10n && window.bwfaL10n.hints,
"there is no hints table to edit");
assert(window.bwfaL10n.hints["bwfa-export-audio"],
"the hint isn't in the strings file");
});
check("a rename is keyed on the name, and only touches that name", () => {
// The mechanism behind bulk renaming, run against the writer the app
// actually ships. Twice this session an edit landed in the modal's
// collector instead of bulk's because the two functions end with the
// same line, and this is the check that catches that class of thing.
const before = '' +
"" +
"" +
"" +
"12A";
const after = window.BWFA.MetadataWriter.applyIxmlFieldEdits(before, [
{ renameTrack: { from: "Boom", to: "Boom (Ambient)" } },
]);
// Every track carrying the name, whatever channel it sits on.
assert.strictEqual(after.split("Boom (Ambient)").length - 1, 2,
"renamed " + (after.split("Boom (Ambient)").length - 1) + " of 2: " + after);
assert(after.indexOf("Lav Anna") !== -1, "it renamed a track it wasn't asked to");
assert(after.indexOf("12A") !== -1, "it disturbed the rest of the iXML");
// And a name nobody has is not an error, it is simply nothing to do.
const untouched = window.BWFA.MetadataWriter.applyIxmlFieldEdits(before, [
{ renameTrack: { from: "Plant FX", to: "Anything" } },
]);
assert(untouched.indexOf("Anything") === -1, "it invented a track");
});
check("clicking a name turns it into a field", () => {
const line = doc.querySelector('[data-bwfa-bulk-track="Boom"]');
assert(line, "no Boom row in the list");
click(line.querySelector(".bwfa-name-chip"));
assert(line.querySelector("input"),
"clicking the name didn't turn it into a field");
});
await checkAsync("a bulk rename reaches every file on the card", async () => {
// The whole point of the feature, and the one thing none of the
// checks above actually did: drive it from the panel and then read
// the files. The writer is unit-tested and the list renders, so a
// break in between reports success over every file and changes none.
const line = doc.querySelector('[data-bwfa-bulk-track="Boom"]');
const input = line.querySelector("input") ||
(click(line.querySelector(".bwfa-name-chip")), line.querySelector("input"));
assert(input, "no field to type the new name into");
input.value = "Boom Bulk";
input.dispatchEvent(new window.KeyboardEvent("keydown",
{ key: "Enter", bubbles: true, cancelable: true }));
const apply = doc.querySelector("[data-bwfa-bulk-edit-apply]");
assert.strictEqual(apply.disabled, false,
"Apply never armed, so the rename wasn't registered at all");
const carrying = fs.readdirSync(path.join(root, "MixPre"))
.filter((n) => /\.wav$/i.test(n))
.filter((n) => fs.readFileSync(path.join(root, "MixPre", n))
.toString("latin1").indexOf("Boom") !== -1);
assert(carrying.length > 0, "no file on the card carries a track called Boom");
click(apply); // arms the confirmation
click(apply); // runs it
await waitFor(() => bulkPanel.hidden === true, "the run to finish", 20000);
const status = doc.querySelector("[data-bwfa-status]").textContent;
assert(/\b0 failed/.test(status), "the run reported failures: " + status);
const missed = carrying.filter((n) => fs.readFileSync(path.join(root, "MixPre", n))
.toString("latin1").indexOf("Boom Bulk") === -1);
assert.strictEqual(missed.length, 0,
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);
});
/* --- the sound report remembers who it's for --- */
click(doc.querySelector("[data-bwfa-report-open]"));
const reportDetails = {
company: "Acme Films",
project: "The Long Weekend",
director: "R. Okonjo",
mixer: "Vincent Rozenberg",
phone: "+31 6 1234 5678",
email: "vincent@example.com",
note: "Day 4, ext. night",
};
Object.keys(reportDetails).forEach((key) => {
const input = doc.querySelector('[data-bwfa-report-field="' + key + '"]');
assert(input, "no report field called " + key);
input.value = reportDetails[key];
});
doc.querySelector("[data-bwfa-report-format]").value = "csv";
click(doc.querySelector("[data-bwfa-report-create]"));
check("the report details survive the next launch", () => {
const stored = window.localStorage.getItem("bwfa_report_details_v1");
assert(stored, "nothing was saved, so it'd all be typed again tomorrow");
const parsed = JSON.parse(stored);
assert.strictEqual(parsed.company, "Acme Films");
assert.strictEqual(parsed.email, "vincent@example.com");
assert.strictEqual(parsed.format, "csv", "the format choice wasn't kept");
});
check("creating the report closes the modal", () => {
assert.strictEqual(doc.querySelector("[data-bwfa-export-modal]").hidden, true,
"the modal stayed up over the save panel");
});
/* --- playback: the engine, and what the page does with it --- */
/* Playback is Rust now. WebKit's audio dies after the machine has been
left alone, the transport keeps running, and only quitting the app
brings sound back — a page reload doesn't, which puts the fault below
anything a page can reach. So the page no longer makes sound: it asks
the engine to, and reads the position back off an event. What follows
is that conversation, which is all of it the page is responsible for. */
const playerBox = doc.querySelector("[data-bwfa-player]");
const playpause = doc.querySelector("[data-bwfa-player-playpause]");
const rowPlay = rowsOf()[0].querySelector("[data-bwfa-play-btn]");
click(rowPlay);
await waitFor(() => engine.state !== null, "the engine to be asked for a file", 5000);
check("playing a file names it in the transport", () => {
assert.strictEqual(playerBox.hidden, false, "the player went away");
assert.strictEqual(playpause.disabled, false, "Play is disabled while playing");
assert(doc.querySelector("[data-bwfa-player-filename]").textContent.length,
"the transport doesn't say what's playing");
assert(/now playing/i.test(doc.querySelector("[data-bwfa-player-badge]").textContent),
"badge: " + doc.querySelector("[data-bwfa-player-badge]").textContent);
});
await checkAsync("space works the player from the main window", async () => {
// The transport at the bottom is the one in use most of the day — a
// file gets auditioned straight from the table, and the mixer is a
// detour. Nothing may be in front for this to mean anything, so shut
// whatever an earlier check left up.
// Hide them by the attribute alone rather than clicking them shut. The
// app is not told, so nothing it holds about them changes and putting
// the attribute back restores exactly the state that was there — the
// checks further down still find the sheet they left open. Clicking
// each closed instead desynced a sheet from the panel inside it.
const inFront = Array.from(doc.querySelectorAll(".modal.open:not([hidden])"));
inFront.forEach((m) => { m.hidden = true; });
try {
assert.strictEqual(doc.querySelectorAll(".modal.open:not([hidden])").length, 0,
"something is still in front, so this would not be testing the main window");
const was = playpause.textContent.trim();
assert(press(doc.body, " "),
"the space wasn't taken, so the window would scroll instead");
await waitFor(() => playpause.textContent.trim() !== was,
"space to work the player at the bottom", 4000);
assert(press(doc.body, " "), "the second space wasn't taken");
await waitFor(() => playpause.textContent.trim() === was,
"space to put the player back where it was", 4000);
} finally {
inFront.forEach((m) => { m.hidden = false; });
}
});
await checkAsync("a dialog in front takes the spacebar back", async () => {
// Settings stands in for all of them: its own screen, its own
// keyboard, and working the transport from underneath it would be a
// surprise. The mixer is the one exception and has its own check.
// Same trick as above, so Settings is demonstrably the thing in
// front — with the leftovers still up this would pass whether the
// rule worked or not.
const inFront = Array.from(doc.querySelectorAll(".modal.open:not([hidden])"));
inFront.forEach((m) => { m.hidden = true; });
const was = playpause.textContent.trim();
try {
click(doc.querySelector("[data-bwfa-columns-open]"));
const up = doc.querySelectorAll(".modal.open:not([hidden])");
assert.strictEqual(up.length, 1,
"expected Settings alone in front, found " + up.length + " dialogs");
assert(!press(doc.body, " "),
"a dialog is in front and the transport took the space anyway");
await new Promise((r) => setTimeout(r, 200));
assert.strictEqual(playpause.textContent.trim(), was,
"space worked the player from under a dialog");
} finally {
click(doc.querySelector("[data-bwfa-columns-close]"));
inFront.forEach((m) => { m.hidden = false; });
}
});
check("nothing in the page opens an audio context any more", () => {
// The whole point of the change, and the one assertion that would
// catch it creeping back: an AudioContext here is the bug returning,
// because it is the thing that dies and cannot be revived.
assert.strictEqual(audio.created, 0,
"the page built " + audio.created + " audio contexts");
assert.strictEqual(audio.starts, 0, "the page started Web Audio playback");
});
check("the engine is asked for the file by path, from the top", () => {
assert.strictEqual(engine.state.path, rowsOf()[0].dataset.bwfaPath || engine.state.path);
assert.strictEqual(engine.state.seconds, 0, "it started part way in");
assert(engine.state.playing, "it was asked for a file but not to play it");
});
await checkAsync("the clock is the engine's, not one the page keeps", async () => {
// The old player counted seconds off an AudioContext, which keeps
// counting perfectly while nothing is being heard. This one can only
// move if the engine says it moved.
const before = doc.querySelector("[data-bwfa-player-elapsed]").textContent;
engine.advance(1);
await waitFor(() =>
doc.querySelector("[data-bwfa-player-elapsed]").textContent !== before,
"the transport to follow the engine", 3000);
assert.strictEqual(doc.querySelector("[data-bwfa-player-elapsed]").textContent,
"00:00:01", "elapsed reads " + doc.querySelector("[data-bwfa-player-elapsed]").textContent);
});
await checkAsync("pause and play go to the engine, and hold the position", async () => {
click(playpause);
await waitFor(() => engine.calls.indexOf("pause") !== -1, "the pause", 3000);
assert.strictEqual(engine.state.playing, false, "the engine was left running");
assert.strictEqual(doc.querySelector("[data-bwfa-player-elapsed]").textContent, "00:00:01",
"the position moved while paused");
click(playpause);
await waitFor(() => engine.state && engine.state.playing, "playing again", 3000);
assert(engine.state.seconds >= 1, "it restarted from " + engine.state.seconds);
});
await checkAsync("the chips send one gain per channel", async () => {
const chips = doc.querySelectorAll("[data-bwfa-channel-chips] [data-channel-index]");
assert(chips.length >= 2, "only " + chips.length + " channels offered");
click(chips[1]);
await waitFor(() => engine.state && engine.state.gains.length === chips.length,
"the gains", 3000);
assert.strictEqual(engine.state.gains[0], 1, "channel 1 was turned down");
assert.strictEqual(engine.state.gains[1], 0, "muting channel 2 didn't reach the engine");
click(chips[1]);
await waitFor(() => engine.state && engine.state.gains[1] === 1, "unmuting", 3000);
});
await checkAsync("the output being rebuilt underneath is said out loud", async () => {
// This is the case that used to be unrecoverable. The engine reopens
// the device and carries on; the page's job is to not leave the gap
// unexplained.
engine.reopen();
await waitFor(() => /reopened/i.test(doc.querySelector("[data-bwfa-status]").textContent),
"the status line", 3000);
});
const playing = doc.querySelector("[data-bwfa-player-filename]").textContent;
engine.finish();
await waitFor(() => !doc.querySelector("[data-bwfa-player-playpause]").disabled &&
engine.state === null, "the file to finish", 5000);
check("a finished file leaves its length and its waveform on screen", () => {
assert(doc.querySelector("[data-bwfa-player-duration]").textContent !== "00:00:00",
"the length went away with the file");
});
check("the transport stays up when a file finishes, holding it ready", () => {
assert.strictEqual(playerBox.hidden, false, "the player vanished with the file");
assert.strictEqual(doc.querySelector("[data-bwfa-player-filename]").textContent, playing,
"it forgot what it just played");
assert.strictEqual(playpause.disabled, false, "there's nothing to press to hear it again");
});
await checkAsync("and its Play button plays that file again", async () => {
click(playpause);
await waitFor(() => engine.state !== null, "the file to start again", 5000);
assert.strictEqual(doc.querySelector("[data-bwfa-player-filename]").textContent, playing);
});
await checkAsync("the spectrogram opens over the page and draws the file", async () => {
// The waveform stays where it is; this is the picture you go and look
// at, so it gets the room.
const open = doc.querySelector("[data-bwfa-spectro-open]");
assert(open, "no spectrogram button in the player");
click(open);
await waitFor(() => engine.calls.indexOf("spectrogram") !== -1,
"the spectrogram to be asked for", 8000);
assert.strictEqual(doc.querySelector("[data-bwfa-spectro]").hidden, false,
"the modal didn't open");
await waitFor(() => /kHz/.test(doc.querySelector("[data-bwfa-spectro-note]").textContent),
"the picture to arrive", 8000);
const canvas = doc.querySelector("[data-bwfa-spectro-canvas]");
assert.strictEqual(canvas.width, 1200, "canvas is " + canvas.width + " wide");
assert.strictEqual(canvas.height, 1024, "canvas is " + canvas.height + " tall");
});
check("the picture has a frequency axis, read top down", () => {
// Nyquist at the top and DC at the bottom, which is the way round
// every spectrogram anybody has read is drawn.
const marks = Array.from(
doc.querySelectorAll("[data-bwfa-spectro-scale] span")).map((s) => s.textContent);
assert(marks.length >= 3, "only " + marks.length + " marks on the axis");
assert(/kHz|Hz/.test(marks[0]), "the axis says nothing: " + marks.join(", "));
const top = parseFloat(marks[0]);
const bottom = parseFloat(marks[marks.length - 1]);
assert(top > bottom, "the axis runs the wrong way: " + marks.join(", "));
assert(Math.abs(top - 24) < 0.2, "the top of a 48k file should be 24 kHz, got " + marks[0]);
});
check("the picture can be saved, and the app asks where", () => {
// Down the same road as every other export: a download link the
// bridge turns into the native save panel.
const save = doc.querySelector("[data-bwfa-spectro-save]");
assert(save, "there is no way to keep the picture");
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.
const asked = engine.lastSpectro;
assert(asked && asked.gains, "no gains were sent");
assert.strictEqual(asked.gains.length,
doc.querySelectorAll("[data-bwfa-channel-chips] [data-channel-index]").length,
"one gain per channel is what the engine expects");
click(doc.querySelector("[data-bwfa-spectro-close]"));
assert.strictEqual(doc.querySelector("[data-bwfa-spectro]").hidden, true,
"it wouldn't close");
});
await checkAsync("the mixer opens with one fader per track, named", async () => {
const open = doc.querySelector("[data-bwfa-mixer-open]");
assert(open, "no mixer knob in the player");
click(open);
const modal = doc.querySelector("[data-bwfa-mixer]");
assert.strictEqual(modal.hidden, false, "the mixer didn't open");
// The class matters as much as the attribute: .modal stays at
// display:none without .open, so "unhidden" can still mean invisible.
assert(modal.classList.contains("open"),
"unhidden but no .open class — the user would see nothing");
const strips = doc.querySelectorAll("[data-bwfa-mixer-strips] .bwfa-mixer-strip");
const chips = doc.querySelectorAll("[data-bwfa-channel-chips] [data-channel-index]");
assert.strictEqual(strips.length, chips.length,
strips.length + " faders for " + chips.length + " tracks");
assert(strips.length > 0, "the file has no tracks to mix");
// The name beside the fader is the track's name, not "Ch 1", when the
// file carries one — you mix "Boom", not a channel number.
const names = Array.from(strips).map(
(s) => s.querySelector(".bwfa-mixer-name").textContent);
const chipNames = Array.from(chips).map((c) => c.textContent);
assert.deepStrictEqual(names, chipNames,
"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");
fader.value = "-6";
fader.dispatchEvent(new window.Event("input", { bubbles: true }));
await waitFor(() => engine.state && engine.state.gains &&
engine.state.gains[0] < 0.9, "the new gain to reach the engine", 4000);
const gains = engine.state.gains;
// -6 dB is half the voltage, near enough: 10^(-6/20) = 0.501.
assert(Math.abs(gains[0] - 0.5012) < 0.005,
"-6 dB should be about 0.50 linear, got " + gains[0]);
for (let i = 1; i < gains.length; i++) {
assert.strictEqual(gains[i], 1,
"track " + (i + 1) + " moved too, at " + gains[i]);
}
const readout = doc.querySelector('[data-bwfa-mixer-db="0"]').textContent;
assert(/-6/.test(readout) || /\u22126/.test(readout),
"the readout says " + readout);
});
await checkAsync("the top of the travel is +6 dB and the bottom is silence", async () => {
const fader = doc.querySelector('[data-bwfa-mixer-fader="0"]');
assert.strictEqual(fader.max, "6", "the fader tops out at " + fader.max + " dB");
fader.value = fader.max;
fader.dispatchEvent(new window.Event("input", { bubbles: true }));
await waitFor(() => engine.state.gains[0] > 1.5, "the boost to land", 4000);
assert(Math.abs(engine.state.gains[0] - 1.9953) < 0.01,
"+6 dB should be about 2.0 linear, got " + engine.state.gains[0]);
fader.value = fader.min;
fader.dispatchEvent(new window.Event("input", { bubbles: true }));
await waitFor(() => engine.state.gains[0] === 0, "the fader to reach silence", 4000);
// Not 0.001: all the way down means off, the way a desk does it.
assert.strictEqual(engine.state.gains[0], 0,
"the bottom of the travel left " + engine.state.gains[0] + " through");
fader.value = "0";
fader.dispatchEvent(new window.Event("input", { bubbles: true }));
await waitFor(() => engine.state.gains[0] === 1, "unity to come back", 4000);
});
await checkAsync("its mute is the player's mute, not a second one", async () => {
const mute = doc.querySelector('[data-bwfa-mixer-mute="1"]');
assert(mute, "no mute on the second track");
click(mute);
await waitFor(() => engine.state && engine.state.gains &&
engine.state.gains[1] === 0, "the mute to reach the engine", 4000);
const chip = doc.querySelectorAll("[data-bwfa-channel-chips] [data-channel-index]")[1];
assert(chip.className.indexOf("is-muted") !== -1,
"the player's chip didn't follow: " + chip.className);
assert(mute.className.indexOf("is-muted") !== -1, "the mixer's own button didn't light");
click(mute);
await waitFor(() => engine.state.gains[1] === 1, "the unmute to land", 4000);
});
await checkAsync("the transport in the mixer is the transport in the player", async () => {
const button = doc.querySelector("[data-bwfa-mixer-playpause]");
assert(button, "no transport in the mixer");
const playerBtn = doc.querySelector("[data-bwfa-player-playpause]");
assert.strictEqual(button.textContent.trim(), playerBtn.textContent.trim(),
"they disagree: mixer says " + button.textContent + ", player says " +
playerBtn.textContent);
const wasPlaying = /pause/i.test(button.textContent);
click(button);
await waitFor(() => /pause/i.test(button.textContent) !== wasPlaying,
"the mixer's button to take effect", 4000);
assert.strictEqual(button.textContent.trim(), playerBtn.textContent.trim(),
"pausing in the mixer left the player's button reading " + playerBtn.textContent);
if (!/pause/i.test(button.textContent)) {
click(button);
await waitFor(() => /pause/i.test(button.textContent), "it to start again", 4000);
}
});
await checkAsync("and it follows the player when the player is the one pressed", async () => {
// The other direction, which is the one that goes stale quietly: pause
// from the player, or let a file end, and the mixer must not sit there
// still reading Pause.
const button = doc.querySelector("[data-bwfa-mixer-playpause]");
const playerBtn = doc.querySelector("[data-bwfa-player-playpause]");
const was = button.textContent.trim();
click(playerBtn);
await waitFor(() => playerBtn.textContent.trim() !== was,
"the player's own button to change", 4000);
await waitFor(() => button.textContent.trim() === playerBtn.textContent.trim(),
"the mixer to follow the player onto " + playerBtn.textContent.trim(), 4000);
click(playerBtn);
await waitFor(() => /pause/i.test(playerBtn.textContent), "playback to resume", 4000);
});
await checkAsync("space works the transport with the mixer open", async () => {
// Hands are on the faders in here, not on the Play button, so space
// has to be the transport the way it is in every editor.
const button = doc.querySelector("[data-bwfa-mixer-playpause]");
const playerBtn = doc.querySelector("[data-bwfa-player-playpause]");
const strips = doc.querySelector("[data-bwfa-mixer-strips]");
assert(button && strips, "the mixer isn't open");
const wasPlaying = /pause/i.test(button.textContent);
assert(press(strips, " "),
"the space wasn't taken: it would scroll the page behind the modal, " +
"and a focused Play would take it as a second click");
await waitFor(() => /pause/i.test(button.textContent) !== wasPlaying,
"space to work the transport", 4000);
assert.strictEqual(button.textContent.trim(), playerBtn.textContent.trim(),
"space moved the mixer but not the player, which now reads " +
playerBtn.textContent);
// Back again off a fader, because a fader is an input and the hand
// that reaches for space in here is usually already holding one.
const fader = doc.querySelector('[data-bwfa-mixer-fader="0"]');
assert(fader, "no fader to press space on");
assert(press(fader, " "), "space on a fader wasn't taken");
await waitFor(() => /pause/i.test(button.textContent) === wasPlaying,
"space on a fader to work the transport too", 4000);
});
await checkAsync("typing and auto-repeat keep the spacebar to themselves", async () => {
const button = doc.querySelector("[data-bwfa-mixer-playpause]");
const was = button.textContent.trim();
// This one closes the mixer and pokes the transport to make its point,
// so it puts both back whatever happens. Every check after it expects
// an open mixer and a file still running, and a broken spacebar that
// reports itself as four dead meters further down sends whoever reads
// the output looking in the wrong place entirely.
const field = doc.createElement("input");
field.type = "text";
try {
// Typing a space into a field is typing, whatever is on screen.
doc.body.appendChild(field);
assert(!press(field, " "), "a space typed into a text field was taken");
// Auto-repeat: holding the key must not toggle thirty times a second.
assert(press(doc.querySelector("[data-bwfa-mixer-strips]"), " ", { repeat: true }),
"the repeat wasn't swallowed, so the page would scroll");
await new Promise((r) => setTimeout(r, 250));
assert.strictEqual(button.textContent.trim(), was,
"a held-down space worked the transport anyway");
} finally {
field.remove();
if (doc.querySelector("[data-bwfa-mixer]").hidden) {
click(doc.querySelector("[data-bwfa-mixer-open]"));
}
if (button.textContent.trim() !== was) {
click(button);
await waitFor(() => button.textContent.trim() === was,
"the transport to go back to " + was, 4000);
}
}
});
await checkAsync("the mixer carries the player's transport, waveform and all", async () => {
// Not a second transport with its own look: the same markup, the same
// drawing code, the same click-to-seek arithmetic.
const wave = doc.querySelector("[data-bwfa-mixer-waveform]");
assert(wave, "no waveform in the mixer");
assert.strictEqual(wave.tagName, "CANVAS", "the waveform is a " + wave.tagName);
assert(wave.className.indexOf("bwfa-player-waveform") !== -1,
"it isn't drawn as the player's waveform: " + wave.className);
assert(!doc.querySelector("[data-bwfa-mixer-seek]"),
"the old slider is still there alongside the waveform");
const button = doc.querySelector("[data-bwfa-mixer-playpause]");
assert(button.className.indexOf("btn-primary") !== -1,
"the transport button isn't the player's: " + button.className);
// jsdom gives every box zero size, so the seek arithmetic needs a real
// rectangle to work against.
wave.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 56 });
const total = engine.state.duration;
wave.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 500 }));
await waitFor(() => engine.state && engine.state.seconds > total * 0.4,
"clicking the middle of the waveform to seek half way", 6000);
assert(Math.abs(engine.state.seconds - total / 2) < total * 0.1,
"clicked half way through " + total.toFixed(2) + "s, engine went to " +
engine.state.seconds.toFixed(2) + "s");
const clock = doc.querySelector("[data-bwfa-mixer-elapsed]").textContent;
assert(/^\d\d:\d\d:\d\d/.test(clock), "the clock reads " + clock);
assert(clock !== "00:00:00", "half way through and the clock still says " + clock);
});
await checkAsync("each track has a meter, fed by the engine", async () => {
// The page holds no audio on this build — playback lives in Rust — so
// the only honest source for a meter is the engine's own status.
const meters = doc.querySelectorAll("[data-bwfa-mixer-meter]");
assert(meters.length > 0, "no meters in the mixer");
assert.strictEqual(meters.length,
doc.querySelectorAll("[data-bwfa-mixer-strips] .bwfa-mixer-strip").length,
"one meter per track is the idea");
// Half scale on the first track, a quarter on the rest.
engine.levels = Array.from(meters).map((_, i) => (i === 0 ? 0.5 : 0.25));
engine.report({});
await waitFor(() => meters[0].getAttribute("data-bwfa-meter-db") !== null,
"the meter to read something", 4000);
const db = parseFloat(meters[0].getAttribute("data-bwfa-meter-db"));
assert(Math.abs(db + 6.02) < 0.2, "half scale should read -6 dB, reads " + db);
const mask = meters[0].querySelector(".bwfa-mixer-meter-mask");
const lit = parseFloat(mask.style.left);
// -6 dB on a scale that bottoms out at -60: (60-6)/60 = 90%.
assert(Math.abs(lit - 90) < 1, "the bar is lit to " + lit + "% for -6 dB");
assert(!meters[0].className.includes("is-hot"), "half scale shouldn't read as hot");
// The colours are fixed to the scale, not to the level: the gradient
// is on the track and the mask hides what isn't lit.
assert(/linear-gradient/.test(css.match(/\.bwfa-mixer-meter \{[^}]*/)[0]),
"the meter track has no gradient, so its colours move with the level");
const marks = Array.from(doc.querySelectorAll("[data-bwfa-mixer-scale] span"))
.map((m) => m.textContent);
assert.deepStrictEqual(marks, ["-60", "-40", "-20", "-6", "0"],
"the dB scale reads " + marks.join(" "));
// And it has to sit over the meters. Separate grids with matching
// column lists do not line up: fr is resolved per container from its
// own contents, so the scale spread across the fader as well.
// It has to be inside the meter's own cell. A scale laid out anywhere
// else lines up with the meters only by luck, and the first time the
// column widths move it stops lining up at all.
const scale = doc.querySelector("[data-bwfa-mixer-scale]");
assert(scale.closest(".bwfa-mixer-meter-cell"),
"the scale is outside the meter's cell, so nothing keeps them the same width");
assert.strictEqual(scale.parentNode,
doc.querySelector('[data-bwfa-mixer-meter="0"]').parentNode,
"the scale and the first meter are not in the same box");
assert.strictEqual(doc.querySelectorAll("[data-bwfa-mixer-scale]").length, 1,
"there is a scale over every meter instead of one over the stack");
// And the meter has to fill that cell. As a bare it is inline
// with no content, so it collapses to nothing: still updated, still
// invisible, which reads as a meter that has stopped working.
const meterRule = css.match(/\.bwfa-mixer-meter \{[^}]*\}/)[0];
assert(/display:\s*block/.test(meterRule),
"the meter is inline, so it has no width of its own: " + meterRule);
assert(/width:\s*100%/.test(meterRule),
"the meter doesn't fill its cell: " + meterRule);
});
await checkAsync("the meter reads the track, not the fader", async () => {
// Pre-fader on purpose: mute a channel and the meter must go on saying
// there is content there. A meter that follows your monitoring choices
// can't tell you whether a take has anything on it.
const meter = doc.querySelector('[data-bwfa-mixer-meter="0"]');
const fader = doc.querySelector('[data-bwfa-mixer-fader="0"]');
fader.value = fader.min;
fader.dispatchEvent(new window.Event("input", { bubbles: true }));
click(doc.querySelector('[data-bwfa-mixer-mute="0"]'));
await waitFor(() => engine.state.gains[0] === 0, "the channel to go silent", 4000);
// Long enough for the ballistics to have fallen away if the meter were
// reading post-fader. Checking straight after one report would pass
// either way, because the bar has not had time to move yet.
engine.levels = [0.25, 0.25];
const started = Date.now();
while (Date.now() - started < 900) {
engine.report({});
await new Promise((r) => setTimeout(r, 60));
}
const db = parseFloat(meter.getAttribute("data-bwfa-meter-db"));
assert(Math.abs(db + 12.04) < 1.5,
"a silenced channel carrying -12 dB of content reads " + db + " dB");
click(doc.querySelector('[data-bwfa-mixer-mute="0"]'));
fader.value = "0";
fader.dispatchEvent(new window.Event("input", { bubbles: true }));
});
await checkAsync("the meter falls back when the level goes away", async () => {
const meter = doc.querySelector('[data-bwfa-mixer-meter="0"]');
const before = parseFloat(meter.getAttribute("data-bwfa-meter-db"));
engine.levels = null;
engine.report({});
await waitFor(() => {
engine.report({});
const now = parseFloat(meter.getAttribute("data-bwfa-meter-db"));
return !(now >= before);
}, "the meter to start falling", 5000);
const after = parseFloat(meter.getAttribute("data-bwfa-meter-db"));
assert(after < before, "held at " + after + " dB with nothing feeding it");
});
await checkAsync("the transport's label survives the pointer", async () => {
// The bug: the label was rewritten on every status tick, ten times a
// second, so a mousedown-then-mouseup over the word could lose its
// click when the node under it was replaced mid-gesture. Now the word
// lives in a span that is only written when the word actually changes.
const button = doc.querySelector("[data-bwfa-mixer-playpause]");
const label = button.querySelector("[data-bwfa-label]");
assert(label, "the transport button has no label span");
let rewrites = 0;
const observer = new window.MutationObserver(() => { rewrites++; });
observer.observe(button, { childList: true, characterData: true, subtree: true });
const word = label.textContent;
await new Promise((r) => setTimeout(r, 600));
observer.disconnect();
assert.strictEqual(label.textContent, word, "the word changed on its own");
assert.strictEqual(rewrites, 0,
"the label was rewritten " + rewrites + " times while nothing changed");
});
await checkAsync("prev and next name the neighbours and move the player", async () => {
const next = doc.querySelector("[data-bwfa-mixer-next]");
// The forward group is pinned to the buttons by a single auto margin.
// Two autos, one on each nav group, split the free space between them
// and leave the arrow stranded mid-footer.
const footerRules = css.match(/\.bwfa-modal-nav-(back|on) \{[^}]*\}/g).join("");
assert.strictEqual((footerRules.match(/margin-\w+:\s*auto/g) || []).length, 1,
"expected exactly one auto margin across the footer navs, found: " + footerRules);
const nextName = doc.querySelector("[data-bwfa-mixer-next-name]");
assert(next && nextName, "no file navigation in the mixer footer");
const wanted = nextName.textContent;
assert(wanted, "the next arrow doesn't say where it goes");
assert.strictEqual(next.disabled, false, "the next arrow is dead mid-list");
click(next);
await waitFor(() =>
doc.querySelector("[data-bwfa-player-filename]").textContent === wanted,
"the player to follow the mixer onto " + wanted, 6000);
assert.strictEqual(doc.querySelector("[data-bwfa-mixer-filename]").textContent, wanted,
"the mixer's own title didn't move");
// And the strips are rebuilt for the file we landed on, not left
// showing the last one's tracks.
await waitFor(() =>
doc.querySelectorAll("[data-bwfa-channel-chips] [data-channel-index]").length > 0,
"the player to redraw its chips", 6000);
assert.strictEqual(
doc.querySelectorAll("[data-bwfa-mixer-strips] .bwfa-mixer-strip").length,
doc.querySelectorAll("[data-bwfa-channel-chips] [data-channel-index]").length,
"the faders are still the previous file's");
// The meters have to survive the change. They are built from whether
// this build reports levels at all, not from whether a level happens
// to have arrived in the instant after the new file opened — reading
// that gap as "no meters here" is what made them disappear.
assert.strictEqual(
doc.querySelectorAll("[data-bwfa-mixer-meter]").length,
doc.querySelectorAll("[data-bwfa-mixer-strips] .bwfa-mixer-strip").length,
"the meters went missing when the file changed");
assert(doc.querySelector("[data-bwfa-mixer-scale]"),
"the dB scale went missing when the file changed");
// Back where the rest of the run expects the transport to be.
click(doc.querySelector("[data-bwfa-mixer-prev]"));
await waitFor(() =>
doc.querySelector("[data-bwfa-player-filename]").textContent !== wanted,
"the previous arrow to walk back", 6000);
click(doc.querySelector("[data-bwfa-mixer-close]"));
const shut = doc.querySelector("[data-bwfa-mixer]");
assert.strictEqual(shut.hidden, true, "the mixer wouldn't close");
assert(!shut.classList.contains("open"), "closing left the .open class behind");
});
await checkAsync("stopping tells the engine to let the file go", async () => {
// The file is held open by the engine, not by the page, so a stop that
// doesn't reach it leaves a handle on a card somebody wants to eject.
const before = engine.calls.filter((c) => c === "stop").length;
click(rowsOf()[1].querySelector("[data-bwfa-play-btn]"));
await waitFor(() => engine.calls.filter((c) => c === "stop").length > before,
"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]"));
assert.deepStrictEqual(sections.map((el) => el.getAttribute("data-bwfa-settings")),
["appearance", "playback", "columns"]);
sections.forEach((section) => {
assert.strictEqual(section.tagName, "DETAILS", "not an accordion: " + section.outerHTML.slice(0, 60));
assert.strictEqual(section.open, false, "it starts open, which is the wall of options again");
assert(section.querySelector("summary").textContent.trim().length, "no heading to click");
});
click(doc.querySelector("[data-bwfa-columns-close]"));
});
check("appearance follows the system until it's told otherwise", () => {
const app = doc.querySelector("[data-bwfa-app]");
assert.strictEqual(app.getAttribute("data-bwfa-theme"), "light",
"the harness reports a light system, so the app should be light");
const modes = Array.from(doc.querySelectorAll("[data-bwfa-theme-mode]"));
assert.deepStrictEqual(modes.map((m) => m.value), ["auto", "light", "dark"]);
assert.strictEqual(modes.filter((m) => m.checked)[0].value, "auto");
});
check("choosing dark turns the whole app over, and is remembered", () => {
click(doc.querySelector("[data-bwfa-columns-open]"));
const dark = doc.querySelector('[data-bwfa-theme-mode][value="dark"]');
dark.checked = true;
dark.dispatchEvent(new window.Event("change", { bubbles: true }));
const app = doc.querySelector("[data-bwfa-app]");
assert.strictEqual(app.getAttribute("data-bwfa-theme"), "dark", "the app didn't switch");
assert.strictEqual(doc.documentElement.style.colorScheme, "dark",
"the engine wasn't told, so scrollbars and form controls stay light");
assert.strictEqual(window.localStorage.getItem("bwfa_theme"), "dark", "not remembered");
// The theme is one ramp: no rule anywhere names a light colour that
// the dark block doesn't turn over.
const dark_rules = shellCss.slice(shellCss.indexOf('[data-bwfa-theme="dark"]'));
assert(/--gray-0:\s*#202225/.test(dark_rules), "the anthracite ramp is missing");
const light = doc.querySelector('[data-bwfa-theme-mode][value="light"]');
light.checked = true;
light.dispatchEvent(new window.Event("change", { bubbles: true }));
assert.strictEqual(app.getAttribute("data-bwfa-theme"), "light", "it wouldn't switch back");
click(doc.querySelector("[data-bwfa-columns-close]"));
});
check("the cog offers what happens when a file finishes", () => {
click(doc.querySelector("[data-bwfa-columns-open]"));
const modes = Array.from(doc.querySelectorAll("[data-bwfa-playback-mode]"));
assert.deepStrictEqual(modes.map((m) => m.value), ["stop", "next", "repeat"],
"modes offered: " + modes.map((m) => m.value).join(", "));
assert.strictEqual(modes.filter((m) => m.checked).length, 1, "no mode is selected");
assert.strictEqual(modes.filter((m) => m.checked)[0].value, "stop",
"the default should be the least surprising one");
click(doc.querySelector("[data-bwfa-columns-close]"));
});
// Working down the list, and looping, both start from the same place: a
// file that has just ended.
const playNext = async (mode) => {
click(doc.querySelector("[data-bwfa-columns-open]"));
const radio = doc.querySelector('[data-bwfa-playback-mode][value="' + mode + '"]');
radio.checked = true;
radio.dispatchEvent(new window.Event("change", { bubbles: true }));
click(doc.querySelector("[data-bwfa-columns-close]"));
// Clicking the Play button of the row that's already playing is a
// pause, and decoding is a promise, so nudge it until the file we want
// is actually the one playing.
const play = rowsOf()[0].querySelector("[data-bwfa-play-btn]");
const playingFirst = () => {
const playback = window.BWFA_STATE.playback;
return !!(playback && playback.isPlaying && /A001/.test(playback.row.parsed.fileName));
};
for (let attempt = 0; attempt < 5 && !playingFirst(); attempt++) {
click(play);
await new Promise((resolve) => setTimeout(resolve, 150));
}
await waitFor(playingFirst, "the first file", 4000);
const started = engine.calls.filter((c) => c === "play").length;
engine.finish();
await waitFor(() => engine.calls.filter((c) => c === "play").length > started,
"the next file to start", 4000).catch(() => {});
return doc.querySelector("[data-bwfa-player-filename]").textContent;
};
const nextUp = await playNext("next");
check("'Play the next file' works down the table as it's sorted", () => {
assert(/A002/.test(nextUp), "it played " + nextUp + " rather than the next row");
});
const looped = await playNext("repeat");
check("'Repeat it' plays the same file again", () => {
assert(/A001/.test(looped), "it moved on to " + looped);
});
check("the choice survives a relaunch", () => {
assert.strictEqual(window.localStorage.getItem("bwfa_playback_mode"), "repeat",
"the mode wasn't remembered");
});
// Back to the default so the checks that follow behave.
click(doc.querySelector("[data-bwfa-columns-open]"));
const stopRadio = doc.querySelector('[data-bwfa-playback-mode][value="stop"]');
stopRadio.checked = true;
stopRadio.dispatchEvent(new window.Event("change", { bubbles: true }));
click(doc.querySelector("[data-bwfa-columns-close]"));
check("the columns cog is in the header cell above Play, not in the toolbar", () => {
const cells = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"));
assert(cells[0].querySelector("[data-bwfa-columns-open]"),
"no cog in the first header cell");
// Play and Details are the first two columns in this build, and the
// Details header cell stays empty.
assert.strictEqual(cells[1].textContent.trim(), "", "the Details header grew a label");
assert.strictEqual(
window.getComputedStyle(doc.querySelector("[data-bwfa-columns-dropdown]")).display,
"none", "the Columns dropdown is still in the toolbar");
const modal = doc.querySelector("[data-bwfa-columns-modal]");
click(cells[0].querySelector("[data-bwfa-columns-open]"));
assert.strictEqual(modal.hidden, false, "the cog didn't open the modal");
assert(!doc.querySelector("[data-bwfa-columns-backdrop]").hidden,
"the page behind isn't dimmed");
click(doc.querySelector("[data-bwfa-columns-close]"));
assert.strictEqual(modal.hidden, true, "Done didn't close it");
});
check("the player offers a way into the playing file's metadata", () => {
const edit = doc.querySelector("[data-bwfa-player-edit]");
assert(edit, "there is no Edit button in the player");
assert.strictEqual(edit.textContent.trim(), "Edit", "it reads: " + edit.textContent);
assert(edit.closest(".bwfa-player-label"), "it isn't next to the filename");
});
check("no button label ends in an ellipsis", () => {
const labelled = Array.from(doc.querySelectorAll(".bwfa-scope .btn, .bwfa-scope button"))
.filter((el) => el.textContent.trim().length);
const trailing = labelled
.map((el) => el.textContent.trim())
.filter((text) => /[…]|\.\.\.$/.test(text));
assert.deepStrictEqual(trailing, [], "still trailing off: " + trailing.join(", "));
});
check("bulk edit has no per-field checkboxes left", () => {
assert.strictEqual(doc.querySelectorAll(".bwfa-bulk-edit-toggle").length, 0,
"the arming checkboxes are still there");
const rows = Array.from(doc.querySelectorAll(".bwfa-bulk-edit-row"));
assert(rows.length >= 10, "expected a row per editable field, got " + rows.length);
const heights = rows
.map((row) => row.querySelector(".form-control, .form-select"))
.filter((el) => el && el.tagName !== "TEXTAREA")
.map((el) => window.getComputedStyle(el).height);
assert(heights.length >= 5, "expected several single-line controls");
assert.strictEqual(new Set(heights).size, 1,
"bulk controls are different heights: " + Array.from(new Set(heights)).join(", "));
});
check("bulk edit opens as a modal, not as a slice of the table's height", () => {
// It used to open as a sibling of the table inside a column exactly as
// tall as the window, and the table is the flexible one (min-height: 0,
// so it may shrink to nothing) — a panel at its natural height squeezed
// the table out of existence, and since the window doesn't scroll, that
// left nothing scrollable anywhere. A modal takes no height from the
// column at all, which is the same guarantee without the arithmetic.
const sheet = doc.querySelector('[data-bwfa-sheet="bulk"]');
assert(sheet, "there is no modal shell around bulk edit");
assert(sheet.contains(bulkPanel), "the panel is not inside the shell");
assert.strictEqual(sheet.hidden, false, "the shell stayed hidden with the panel open");
assert(sheet.classList.contains("open"), "the framework needs .open to show a modal");
const shell = window.getComputedStyle(sheet);
assert.strictEqual(shell.position, "fixed",
"the shell is in the layout flow, so it still competes with the table");
const dialog = window.getComputedStyle(sheet.querySelector(".bwfa-sheet-dialog"));
assert.strictEqual(dialog.overflowY, "auto", "a long field list would have nowhere to scroll");
const cap = parseFloat(dialog.maxHeight);
assert(cap > 0 && cap < window.innerHeight,
"the dialog is uncapped, so a long list would run off the screen: " + dialog.maxHeight);
const backdrop = doc.querySelector('[data-bwfa-sheet-backdrop="bulk"]');
assert(backdrop && !backdrop.hidden, "the page behind isn't dimmed");
});
check("closing the modal is the app's own Cancel, not a separate path", () => {
const sheet = doc.querySelector('[data-bwfa-sheet="bulk"]');
doc.querySelector('[data-bwfa-sheet-close="bulk"]')
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
assert.strictEqual(bulkPanel.hidden, true, "the panel is still open");
assert.strictEqual(sheet.hidden, true, "the shell stayed up with nothing in it");
// Put it back for the checks that follow.
click(doc.querySelector("[data-bwfa-bulk-edit-toggle]"));
assert.strictEqual(bulkPanel.hidden, false, "bulk edit wouldn't reopen");
});
check("every bulk field can express 'leave this alone'", () => {
// With the checkboxes gone, blank is what means "skip" — so every
// control has to have a blank state. A bare checkbox wouldn't, which
// is why the two boolean fields are selects now.
const rows = Array.from(doc.querySelectorAll(".bwfa-bulk-edit-row"));
rows.forEach((row) => {
const control = row.querySelector("input, select, textarea");
assert(control, "a row with no control");
assert.notStrictEqual(control.type, "checkbox",
"a checkbox has no blank state: " + row.textContent.trim().slice(0, 30));
assert.strictEqual(control.value, "",
"field starts pre-filled, so it would be written unasked: " +
row.textContent.trim().slice(0, 30) + " = " + control.value);
});
assert.strictEqual(doc.querySelector("[data-bwfa-bulk-edit-apply]").disabled, true,
"Apply is live with nothing filled in");
});
check("filling one field arms Apply, and only that field is collected", () => {
const rows = Array.from(doc.querySelectorAll(".bwfa-bulk-edit-row"));
const sceneRow = rows.find((row) => /^scene/i.test(row.textContent.trim()));
assert(sceneRow, "no Scene row");
const field = sceneRow.querySelector("input");
field.value = "99";
field.dispatchEvent(new window.Event("input", { bubbles: true }));
const apply = doc.querySelector("[data-bwfa-bulk-edit-apply]");
assert.strictEqual(apply.disabled, false, "Apply still disabled after typing");
assert(/\d/.test(apply.textContent), "Apply should name the file count: " + apply.textContent);
const stillBlank = rows
.map((row) => row.querySelector("input, select, textarea"))
.filter((el) => el !== field)
.every((el) => el.value === "");
assert(stillBlank, "typing in one field changed another");
field.value = "";
field.dispatchEvent(new window.Event("input", { bubbles: true }));
assert.strictEqual(apply.disabled, true, "clearing the field should disarm Apply");
});
doc.querySelector("[data-bwfa-bulk-edit-cancel]").dispatchEvent(
new window.MouseEvent("click", { bubbles: true }));
click(saveBtn);
// The button itself reports the outcome; the status line is only used for
// save failures.
await waitFor(() => /saved/i.test(saveBtn.textContent), "save to report success", 10000);
check("save reported success, not an error", () => {
assert(!/could not save/i.test(status.textContent), status.textContent);
});
const after = fs.readFileSync(target);
check("the edit reached the actual file on disk", () => {
const text = after.toString("latin1");
assert(/88Z<\/SCENE>/.test(text), "scene not written");
assert(/retake/.test(text), "note not written");
assert(!/12A<\/SCENE>/.test(text), "old scene still present");
});
check("audio data survived the write untouched", () => {
const dataAt = (buf) => {
const index = buf.indexOf(Buffer.from("data", "latin1"));
const size = buf.readUInt32LE(index + 4);
return buf.slice(index + 8, index + 8 + size);
};
assert(dataAt(before).equals(dataAt(after)), "audio bytes changed");
assert.strictEqual(before.length, after.length, "file length changed (in-place patch expected)");
});
// Re-parse the written file with the app's own parser: the strongest check
// that the bytes are still a valid BWF and not just textually right.
const rereadBytes = new window.Uint8Array(after.length);
rereadBytes.set(after);
const reread = await window.BWFA.RiffParser.parseFile(
new window.File([rereadBytes], "A001_12A_T1.wav", { type: "audio/wav" }));
check("the written file parses back cleanly", () => {
assert.strictEqual(reread.ixml.scene, "88Z", JSON.stringify(reread.ixml && reread.ixml.scene));
assert.strictEqual(reread.format.sampleRate, 48000, "sample rate broken");
assert.strictEqual(reread.format.numChannels, 2, "channel count broken");
assert.strictEqual(reread.format.bitsPerSample, 24, "bit depth broken");
assert.strictEqual(reread.startTimecode, "10:00:00:00", "timecode broken: " + reread.startTimecode);
const errors = reread.errors || [];
assert.strictEqual(errors.length, 0, "parser reported errors: " + errors.join(","));
});
/* --- the player still holds a finished file --- */
click(doc.querySelector("[data-bwfa-modal-close]"));
click(rowsOf()[0].querySelector("[data-bwfa-play-btn]"));
await waitFor(() => window.BWFA_STATE.playback, "a file to be playing", 4000);
engine.finish();
await waitFor(() => engine.state === null, "the file to finish", 4000).catch(() => {});
check("and Edit still opens the file that just played", () => {
const row = window.BWFA_STATE.playerRow();
assert(row, "the player let go of the file the moment it finished");
click(doc.querySelector("[data-bwfa-player-edit]"));
assert.strictEqual(doc.querySelector("[data-bwfa-modal]").hidden, false,
"Edit did nothing with a finished file");
click(doc.querySelector("[data-bwfa-modal-close]"));
});
/* --- dropping a folder does the same thing as picking one --- */
click(doc.querySelector("[data-bwfa-modal-close]"));
emitTauri("tauri://drag-enter", { paths: [], position: { x: 0, y: 0 } });
check("drag highlights the drop target", () =>
assert(doc.querySelector("[data-bwfa-edit-entry]").classList.contains("is-dragover")));
const droppedFolder = path.join(root, "MixPre", "Day14");
dialogQueue = []; // Nothing should reach the dialog: the path is already known.
emitTauri("tauri://drag-drop", { paths: [droppedFolder], position: { x: 10, y: 10 } });
await waitFor(() => rowsOf().length === 1, "dropped folder to load", 10000);
check("dropping a folder opens it read-write, no dialog", () => {
assert.strictEqual(rowsOf().length, 1, "expected just the one file in Day14");
assert(/A003_14B_T3/.test(rowsOf()[0].textContent), rowsOf()[0].textContent);
assert.strictEqual(doc.querySelector("[data-bwfa-editing-note]").hidden, false, "not in edit mode");
assert.strictEqual(doc.querySelector("[data-bwfa-edit-entry]").classList.contains("is-dragover"), false,
"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");
fs.mkdirSync(emptyFolder, { recursive: true });
fs.writeFileSync(path.join(emptyFolder, "notes.txt"), "no recordings here");
assert(rowsOf().length > 0, "expected rows to still be loaded before this case");
dialogQueue = [emptyFolder];
click(doc.querySelector("[data-bwfa-edit-folder]"));
await waitFor(() => /no bwf\/wav files/i.test(status.textContent), "empty folder warning", 8000);
check("an empty folder empties the table", () => {
// The analyser reports "no files found" and returns without touching
// anything, which left the previous folder's rows, its player and its
// playback on screen under a warning about a folder they had nothing to
// do with.
assert.strictEqual(rowsOf().length, 0,
"still showing " + rowsOf().length + " rows from the previous folder");
assert.strictEqual(doc.querySelector("[data-bwfa-results]").hidden, true,
"the results panel is still up");
assert.strictEqual(doc.querySelector("[data-bwfa-player]").hidden, true,
"the player survived the folder change");
assert(/no bwf\/wav files/i.test(status.textContent), status.textContent);
assert(doc.querySelector("[data-bwfa-status]").classList.contains("has-error"),
"the warning isn't flagged as one");
});
check("the folder bar names the folder you actually chose", () =>
assert.strictEqual(doc.querySelector("[data-bwfa-current-folder]").textContent, "Empty Card",
"named: " + doc.querySelector("[data-bwfa-current-folder]").textContent));
check("no script errors on the page", () =>
assert.strictEqual(consoleErrors.length, 0, consoleErrors.join(" | ")));
console.log("\n" + (TINY ? "chunked transfers (tiny limits)" : "default transfer limits"));
results.forEach(([s, n]) => console.log((s === "PASS" ? " ok " : " FAIL") + " " + n));
const failed = results.filter(([s]) => s === "FAIL").length;
console.log("\n" + (results.length - failed) + "/" + results.length + " checks passed");
fs.rmSync(root, { recursive: true, force: true });
process.exit(failed ? 1 : 0);
})().catch((e) => {
console.error("harness error:", e);
console.error(consoleErrors.join("\n"));
process.exit(1);
});