Reads and edits BWF metadata for production sound. One source tree builds a single self-contained page and a native Tauri app with a Rust audio engine and WAV writer. Around 370 checks across seven test suites. First commit of the existing state, so that from here every change can be seen and undone.
2218 lines
103 KiB
JavaScript
2218 lines
103 KiB
JavaScript
/**
|
|
* 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 }));
|
|
}
|
|
|
|
(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"));
|
|
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");
|
|
assert.strictEqual(doc.querySelector("[data-bwfa-player-playpause]").disabled, true,
|
|
"Play is offered with nothing loaded");
|
|
assert.strictEqual(doc.querySelector("[data-bwfa-channel-row]").hidden, true,
|
|
"the channel hint is up with no channels to mute");
|
|
});
|
|
|
|
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("<TRACK_LIST>"), text.indexOf("</TRACK_LIST>"));
|
|
assert(/<CHANNEL_INDEX>2<\/CHANNEL_INDEX>[\s\S]*?Lav Marieke/.test(list) ||
|
|
/Lav Marieke[\s\S]*?<CHANNEL_INDEX>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);
|
|
});
|
|
});
|
|
|
|
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 = '<?xml version="1.0"?><BWFXML><TRACK_LIST>' +
|
|
"<TRACK><CHANNEL_INDEX>1</CHANNEL_INDEX><NAME>Boom</NAME></TRACK>" +
|
|
"<TRACK><CHANNEL_INDEX>2</CHANNEL_INDEX><NAME>Lav Anna</NAME></TRACK>" +
|
|
"<TRACK><CHANNEL_INDEX>3</CHANNEL_INDEX><NAME>Boom</NAME></TRACK>" +
|
|
"</TRACK_LIST><SCENE>12A</SCENE></BWFXML>";
|
|
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("<SCENE>12A</SCENE>") !== -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");
|
|
});
|
|
|
|
|
|
/* --- 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);
|
|
});
|
|
|
|
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("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(", "));
|
|
});
|
|
|
|
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("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 <span> 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);
|
|
});
|
|
|
|
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(/<SCENE>88Z<\/SCENE>/.test(text), "scene not written");
|
|
assert(/retake/.test(text), "note not written");
|
|
assert(!/<SCENE>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");
|
|
});
|
|
|
|
/* --- 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);
|
|
});
|