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