Drop the browser build; space works the transport; fix bulk track rename
The browser page is gone, with its shim, its shell CSS and its suite. build.py has no --tauri flag and one output: the app's page. The patch lists lose the SHARED_ prefix, which only ever meant "shared between the two builds", and test-pdf.js was quietly loading the browser page — it reads the app's now. Space plays and pauses, in the main window off the player at the bottom and in the mixer off the same transport. It is swallowed rather than passed on: that stops the window scrolling, and stops a focused button taking the same press as a second activation, which toggles twice and looks like a dead key. Typing, and any dialog other than the mixer, keep it. Deciding what is in front needs both markers — closing does not always remove .open, and .modal without it is display:none regardless of the hidden attribute. The mixer footer put the next file's name adrift in the middle of the row. The margin-left: auto meant to park it beside Close could do nothing, because the framework's flex: 1 1 auto had both nav groups growing to fill the row and left no free space to absorb. Pinned to flex: 0 0 auto. The file-detail modal shares the markup and follows. Bulk track rename wrote nothing, on every file, and had never worked. A rename is keyed on the name it replaces rather than a path, so the frame-rate pass threw reading edit.path off it — before any byte was written, so nothing was corrupted, only untouched. The failure handler discarded the reason and the run still ended on a tidy Done, which is why 0 saved read as progress; it now names the file and the cause in the log. 429 checks. The gap that hid the rename bug was end-to-end: the writer was unit tested and the panel rendered, and nothing drove one into the other.
This commit is contained in:
+3
-4
@@ -6,11 +6,10 @@ mac-app/*.app/
|
||||
|
||||
node_modules/
|
||||
|
||||
# Built pages. Generated by build/build.py from everything in build/src,
|
||||
# The built page. Generated by build/build.py from everything in build/src,
|
||||
# build/body.html and build/overrides.css, and stamped with a build time, so
|
||||
# they change on every run and would make every diff unreadable. Run
|
||||
# `python3 build/build.py` (and `--tauri`) to produce them.
|
||||
/index.html
|
||||
# it changes on every run and would make every diff unreadable. Run
|
||||
# `python3 build/build.py` to produce it.
|
||||
/mac-app/dist/index.html
|
||||
|
||||
.DS_Store
|
||||
|
||||
@@ -2,55 +2,65 @@
|
||||
|
||||
Reads and edits BWF (Broadcast Wave) metadata for production sound: scene,
|
||||
take, timecode, track names, plus export, split, combine and a sound report.
|
||||
Two things get built from one source tree:
|
||||
|
||||
- `index.html` at the root: a single self-contained page, no server, no
|
||||
network, opens in a browser.
|
||||
- `mac-app/`: a native macOS app (Tauri v2 + Rust) that wraps the same page
|
||||
and adds real file access, a Rust audio engine and a Rust WAV writer.
|
||||
One thing gets built: `mac-app/`, a native macOS app (Tauri v2 + Rust) whose
|
||||
frontend is a single self-contained page, wrapping real file access, a Rust
|
||||
audio engine and a Rust WAV writer.
|
||||
|
||||
There used to be a second, browser-only page built from the same sources, and
|
||||
much of the shape of this repo still shows it — a vendored WordPress plugin
|
||||
patched on the way in, a `shim`/`bridge` split, tests that load a page into
|
||||
jsdom. It is gone. Don't build it, don't write for it, and don't reintroduce a
|
||||
`--tauri` flag or a second output: there is one page and one build.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
python3 build/build.py # writes ./index.html
|
||||
python3 build/build.py --tauri # writes ./mac-app/dist/index.html
|
||||
python3 build/build.py # writes ./mac-app/dist/index.html
|
||||
```
|
||||
|
||||
Both, every time: a change to shared code affects both pages, and a feature
|
||||
wired into only one looks exactly like a broken feature (a visible button that
|
||||
does nothing). There is a test for that specific trap.
|
||||
|
||||
The Mac app is built by double-clicking `mac-app/Build BWF Analyser.command`.
|
||||
That script regenerates the page, touches `main.rs` so the page is re-embedded
|
||||
(cargo does not reliably notice a changed `dist/`), compiles, assembles and
|
||||
signs the bundle.
|
||||
|
||||
`cargo build` on its own is not that. It produces the bare binary and leaves
|
||||
the bundle alone, so the `.app` sitting there is still the old one — which
|
||||
looks exactly like a change that didn't work.
|
||||
|
||||
**Restart the app after compiling it, every time, without being asked.** Quit
|
||||
the running copy and open the newly built one, so there is something on screen
|
||||
to try rather than a description of what should happen. A compiled-but-not-
|
||||
restarted app goes on showing the previous page and nothing in the window says
|
||||
so. Then **say in the reply that the app was rebuilt and relaunched**, and give
|
||||
the build stamp it should be showing — `window.BWFA_BUILD`, also on
|
||||
`data-bwfa-build`, and printed by the build script. Stating it is the point:
|
||||
it lets a mismatch be spotted from the reply instead of being mistaken for a
|
||||
broken feature.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
node build/test.js # the browser page, in jsdom
|
||||
node build/test-tauri.js # the Mac page against a stubbed engine
|
||||
node build/test-tauri.js # the page against a stubbed engine
|
||||
node build/test-export.js # every export/split/combine/format combination
|
||||
node build/test-play.js # playback maths, peaks, spectrogram, meters
|
||||
node build/test-restore.js build/test-framerate.js build/test-pdf.js
|
||||
BWF_TINY_CHUNKS=1 node build/test-tauri.js # tiny-read paths
|
||||
```
|
||||
|
||||
All of them should pass before anything ships. Roughly 370 checks.
|
||||
All of them should pass before anything ships. Roughly 420 checks.
|
||||
|
||||
## How the build works, and its traps
|
||||
|
||||
`build/build.py` inlines CSS and JS into one HTML file. The app's JavaScript
|
||||
(`build/src/bwf-analyser-app.js`) is vendored from a WordPress plugin and is
|
||||
kept pristine: changes to it are made as **exact-string patches** in lists in
|
||||
`build.py` (`SHARED_APP_JS_PATCHES`, `APP_JS_PATCHES`, `PLAYER_PATCHES`,
|
||||
`SPECTRO_PATCHES`, `MIXER_PATCHES`). Each patch asserts its anchor is found
|
||||
exactly once, so a stale anchor fails the build loudly rather than silently
|
||||
doing nothing.
|
||||
`build.py` (`BASE_APP_JS_PATCHES`, `APP_JS_PATCHES`, `PLAYER_PATCHES`,
|
||||
`SPECTRO_PATCHES`, `MIXER_PATCHES`, `TRANSPORT_KEY_PATCHES`). A patch whose
|
||||
anchor has gone stops the build rather than silently doing nothing.
|
||||
|
||||
- Patch strings are Python **non-raw** triple-quoted strings, so tabs in the
|
||||
JavaScript are written `\t`. Get this wrong and the anchor never matches.
|
||||
- `SHARED_*` patches apply to both builds; the others only to `--tauri`.
|
||||
- When inserting into the page, use the **last** `</body>`, not the first: the
|
||||
page contains inlined JavaScript that writes HTML, and the first match is
|
||||
inside a string literal.
|
||||
@@ -84,8 +94,12 @@ area, that a grid is scrambled, or that an element collapsed to zero width.
|
||||
Every one of those has shipped at least once. Layout changes need a screenshot
|
||||
from the user; keep them small and ask.
|
||||
|
||||
**Two pages, one behaviour.** If a feature only makes sense on the Mac build,
|
||||
it still needs to not look broken on the other one.
|
||||
**"Is a modal on screen" needs both markers.** `.modal` is `display: none`
|
||||
without `.open`, and closing does not always take `.open` back off — so a
|
||||
modal that has been opened once and shut again still carries it. Neither the
|
||||
class nor the `hidden` attribute answers the question alone:
|
||||
`.modal.open:not([hidden])` does. Modals also stack (the mixer opens over the
|
||||
file sheet), so "something else is open" says nothing about what is in front.
|
||||
|
||||
**The page stamps itself.** `window.BWFA_BUILD` holds the build time, also on
|
||||
`data-bwfa-build` on the app root. Use it to settle "is the app running the
|
||||
@@ -105,7 +119,7 @@ attribute.
|
||||
|
||||
```
|
||||
build/
|
||||
build.py assembles both pages, holds all the patches
|
||||
build.py assembles the page, holds all the patches
|
||||
body.html markup
|
||||
overrides.css the app's own styling on top of the plugin's
|
||||
l10n.json every string, plus hints
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# BWF Analyser — standalone
|
||||
|
||||
Two builds of the same tool, from the same source.
|
||||
**`mac-app/`** — a real macOS app. Double-click **Build BWF Analyser.command** once and it produces `BWF Analyser.app` next to itself. No server, no internet, no dependencies to install beyond Rust; everything the WordPress plugin did is in it, jsPDF included.
|
||||
|
||||
**`index.html`** — the browser version. Double-click it and it runs: no server, no build step, no internet. Everything the WordPress plugin did is in that one file, jsPDF included.
|
||||
|
||||
**`mac-app/`** — a real macOS app. Double-click **Build BWF Analyser.command** once and it produces `BWF Analyser.app` next to itself.
|
||||
There was a second, browser-only build of the same source — a self-contained `index.html` you opened directly. It has been removed, along with its shim, its shell CSS and its test suite. Passages below that compare the app against "the browser build" are describing that history, not something you can still build.
|
||||
|
||||
## The Mac app
|
||||
|
||||
@@ -20,7 +18,7 @@ Rebuilding replaces the `.app` in place — nothing to delete first. If it's run
|
||||
|
||||
Nothing is downloaded except Rust and the crates it needs. The app itself never touches the network.
|
||||
|
||||
### How it differs from the browser build
|
||||
### How it differs from the plugin it came from
|
||||
|
||||
It's editor-only. There's one way in — **Open Folder**, or drop a folder anywhere in the window — and it opens every recording read-write. The browser build's read-only Select Folder / Select Files / Clear row is gone, along with the page framing and the in-app title: the app fills the window at any width (the plugin's 960px centred column is lifted), the window's own title bar is left blank next to the traffic lights, and the table header stays put while you scroll a long day.
|
||||
|
||||
@@ -48,9 +46,9 @@ The first time you pick a folder inside Documents, Desktop or Downloads, macOS m
|
||||
|
||||
Since you built it yourself, there's no Gatekeeper warning. It also isn't notarised, so copying the `.app` to another machine will trip Gatekeeper there.
|
||||
|
||||
### Layout fixes, both builds
|
||||
### Layout fixes
|
||||
|
||||
Both live in `build/overrides.css`, which applies to the browser build too, without touching the plugin's own stylesheet.
|
||||
These live in `build/overrides.css`, on top of the plugin's own stylesheet rather than inside it.
|
||||
|
||||
The detail modal laid its metadata out with `repeat(auto-fill, minmax(180px, 1fr))`, and `dt`/`dd` are separate grid items flowing in sequence — so any odd number of columns splits the pairs, and every other row reads inside out. It's pinned to exactly two label/value pairs per row now, dropping to one when the window is narrow, and the modal is wider to suit.
|
||||
|
||||
@@ -329,22 +327,14 @@ Text selection is macOS blue rather than the framework's near-black.
|
||||
|
||||
The PHP is gone. The shortcode markup is now static HTML and the `wp_localize_script` strings live in a plain `window.bwfaL10n` object near the bottom of the file — edit any value there to relabel the UI. jsPDF is bundled inline instead of fetched from cdnjs.
|
||||
|
||||
In the browser build, one thing was added: a warning if the browser blocks the folder-write permission. Opening a page from a `file://` address gives it no real origin, and Chrome refuses the File System Access API on that basis, so metadata editing needs the folder served over http:
|
||||
|
||||
```
|
||||
cd /path/to/this/folder
|
||||
python3 -m http.server
|
||||
```
|
||||
|
||||
Then open <http://localhost:8000/>. Or just use the Mac app, where this doesn't come up.
|
||||
The browser build used to need a warning here, because a page opened from a `file://` address has no real origin and Chrome refuses the File System Access API on that basis, so editing metadata meant serving the folder over http first. The app routes every read and write through Rust, so none of that arises.
|
||||
|
||||
## Rebuilding
|
||||
|
||||
`build/` holds the sources and the assembler. `build/pdf-writer.js` is the rewritten PDF writer itself, read by `build.py` and substituted for the plugin's own — 200 lines of drawing code is unreadable as a patch string.
|
||||
|
||||
```
|
||||
python3 build/build.py # -> index.html (browser)
|
||||
python3 build/build.py --tauri # -> mac-app/dist/index.html
|
||||
python3 build/build.py # -> mac-app/dist/index.html
|
||||
python3 build/make-icons.py # regenerates the app icon and .icns
|
||||
```
|
||||
|
||||
@@ -355,14 +345,13 @@ To pick up a new version of the plugin, drop its CSS/JS into `build/src/` and re
|
||||
`npm i jsdom` first, then:
|
||||
|
||||
```
|
||||
node build/test.js # browser build — 35 checks
|
||||
node build/test-tauri.js # Mac app frontend + bridge — 94 checks
|
||||
node build/test-tauri.js # app frontend + bridge — 114 checks
|
||||
node build/test-framerate.js # frame-rate writing across all five fields — 11 checks
|
||||
node build/test-pdf.js # PDF geometry, clipping, wrapping, the report header — 12 checks
|
||||
node build/test-restore.js # remembering, reopening and ejecting the folder — 8 checks
|
||||
node build/test-play.js # waveform bucketing, resampling, the channel sum, the clock, the FFT — 25 checks
|
||||
node build/test-export.js # conversion, normalising, track picking, combining, naming, the report — 140 checks
|
||||
BWF_TINY_CHUNKS=1 node build/test-tauri.js # every transfer forced to chunk — 95 checks
|
||||
BWF_TINY_CHUNKS=1 node build/test-tauri.js # every transfer forced to chunk — 115 checks
|
||||
```
|
||||
|
||||
The Tauri suite re-implements the Rust commands in Node, mirroring them exactly, and runs the real bridge and the real analyser against real files on disk. Web Audio is faked, so an interrupted session, one that refuses to resume, and one whose clock stops while it still claims to be running can all be staged on purpose. It covers opening a folder read-write, timecode reconstruction, a metadata edit landing on the actual file with the audio bytes untouched and the result re-parsing cleanly, both exports being written where the save panel pointed, and a dropped folder taking the same route as a picked one.
|
||||
|
||||
@@ -46,14 +46,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="bwfa-fs-warning" data-bwfa-fs-warning>
|
||||
Your browser blocked the folder-write permission because this page was opened straight from disk
|
||||
(a <code>file://</code> address), where writing to local files isn't allowed. Reading, playback and
|
||||
export all still work. To edit metadata, serve this folder over http instead: run
|
||||
<code>python3 -m http.server</code> in the folder holding this file, then open
|
||||
<code>http://localhost:8000/</code>.
|
||||
</p>
|
||||
|
||||
<div class="bwfa-status" data-bwfa-status role="status" aria-live="polite"></div>
|
||||
|
||||
<div class="bwfa-progress progress" data-bwfa-progress-wrap hidden>
|
||||
|
||||
+164
-61
@@ -1,15 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble the standalone single-file BWF Analyser.
|
||||
"""Assemble the single-file frontend for the BWF Analyser Mac app.
|
||||
|
||||
Reads the original plugin's CSS/JS (unmodified), the ported markup, the
|
||||
generated l10n object and a copy of jsPDF, and writes one index.html that
|
||||
runs with no network access at all.
|
||||
runs with no network access at all. Rust embeds that file into the binary.
|
||||
|
||||
python3 build/build.py -> ./index.html (open in a browser)
|
||||
python3 build/build.py --tauri -> ./mac-app/dist/index.html (Tauri frontend)
|
||||
python3 build/build.py -> ./mac-app/dist/index.html
|
||||
|
||||
The Tauri variant swaps the file:// permission warning for the native bridge,
|
||||
which routes every read and write through Rust.
|
||||
There used to be a second, browser-only page built from the same sources.
|
||||
It is gone: every read and write now goes through the native bridge into
|
||||
Rust, and there is one page and one build.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -17,11 +17,9 @@ import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
TAURI = "--tauri" in sys.argv
|
||||
|
||||
BUILD = pathlib.Path(__file__).resolve().parent
|
||||
SRC = BUILD / "src"
|
||||
OUT = (BUILD.parent / "mac-app" / "dist" / "index.html") if TAURI else (BUILD.parent / "index.html")
|
||||
OUT = BUILD.parent / "mac-app" / "dist" / "index.html"
|
||||
|
||||
|
||||
def read(path):
|
||||
@@ -176,6 +174,13 @@ FRAME_RATE_WRITER_JS = r''' /* ------------------------------------------------
|
||||
var flagEdit = null;
|
||||
|
||||
ixmlEdits.forEach( function ( edit ) {
|
||||
// Not every edit is addressed by a path. A bulk track rename is
|
||||
// keyed on the name it is replacing — { renameTrack: … }, no path
|
||||
// at all — and reading one off it threw before a single byte was
|
||||
// written, which failed every file in the run.
|
||||
if ( ! edit.path ) {
|
||||
return;
|
||||
}
|
||||
var leaf = edit.path[ edit.path.length - 1 ];
|
||||
if ( leaf === "TIMECODE_RATE" ) {
|
||||
rateEdit = edit;
|
||||
@@ -654,10 +659,10 @@ EXPORT_PICKER_JS = r''' /* ---- export field picker ---- */
|
||||
|
||||
'''
|
||||
|
||||
# Correctness patches, applied to both builds: the analyser writes frame rate
|
||||
# the way the standards define it, in every place a reader might look, plus the
|
||||
# frame rate in the table and a picker for what the exports carry.
|
||||
SHARED_APP_JS_PATCHES = [
|
||||
# Correctness patches: the analyser writes frame rate the way the standards
|
||||
# define it, in every place a reader might look, plus the frame rate in the
|
||||
# table and a picker for what the exports carry.
|
||||
BASE_APP_JS_PATCHES = [
|
||||
# ---- sample rate is a list, not a free field ------------------------
|
||||
#
|
||||
# There are maybe nine sample rates in professional use and a typo in
|
||||
@@ -931,7 +936,7 @@ SHARED_APP_JS_PATCHES = [
|
||||
|
||||
|
||||
|
||||
FRAME_RATE_SHARED_PATCHES = [
|
||||
FRAME_RATE_PATCHES = [
|
||||
(
|
||||
''' function writeMetadataToFile( row, edits ) {''',
|
||||
FRAME_RATE_WRITER_JS + ''' function writeMetadataToFile( row, edits ) {''',
|
||||
@@ -1525,10 +1530,10 @@ PLAYBACK_PATCHES = [
|
||||
]
|
||||
|
||||
|
||||
SHARED_APP_JS_PATCHES = SHARED_APP_JS_PATCHES + FRAME_RATE_SHARED_PATCHES + PLAYBACK_PATCHES
|
||||
BASE_APP_JS_PATCHES = BASE_APP_JS_PATCHES + FRAME_RATE_PATCHES + PLAYBACK_PATCHES
|
||||
|
||||
|
||||
# Tauri-only patches to the analyser's JavaScript. The file in build/src stays
|
||||
# Further patches to the analyser's JavaScript. The file in build/src stays
|
||||
# exactly as the plugin ships it; these are applied on the way into the app
|
||||
# build, and every one has to match or the build stops — a silent no-op here
|
||||
# would look like a UI regression later.
|
||||
@@ -2263,6 +2268,97 @@ MIXER_PATCHES = [
|
||||
]
|
||||
|
||||
|
||||
# ---- space is the transport ------------------------------------------
|
||||
# The way it is in every editor: space plays and pauses, in the main window
|
||||
# off the player at the bottom, and in the mixer off the same transport.
|
||||
# It goes in here rather than in MIXER_PATCHES because it is not the mixer's
|
||||
# key — the mixer is just one of the two places it has to work — but it is
|
||||
# inserted into that closure, which is where mixerOpen and togglePlayPause
|
||||
# both live.
|
||||
#
|
||||
# Swallowing the key earns its place twice over: it stops the page
|
||||
# scrolling, and it stops a focused button taking the same press as a second
|
||||
# activation. Two toggles in one press looks exactly like a dead spacebar.
|
||||
TRANSPORT_KEY_PATCHES = [
|
||||
(
|
||||
"""\t\tcontainer.addEventListener( "click", function ( e ) {
|
||||
\t\t\tif ( e.target.closest( "[data-bwfa-mixer-open]" ) ) {
|
||||
\t\t\t\topenMixer();
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}""",
|
||||
"""\t\t/** True where a space is a character being typed, not a command. */
|
||||
\t\tfunction typingInField( node ) {
|
||||
\t\t\tif ( ! node || ! node.tagName ) {
|
||||
\t\t\t\treturn false;
|
||||
\t\t\t}
|
||||
\t\t\tif ( node.isContentEditable ) {
|
||||
\t\t\t\treturn true;
|
||||
\t\t\t}
|
||||
\t\t\tvar tag = node.tagName.toLowerCase();
|
||||
\t\t\tif ( tag === "textarea" || tag === "select" ) {
|
||||
\t\t\t\treturn true;
|
||||
\t\t\t}
|
||||
\t\t\tif ( tag !== "input" ) {
|
||||
\t\t\t\treturn false;
|
||||
\t\t\t}
|
||||
\t\t\t// The faders are inputs too, and a space with one under the finger
|
||||
\t\t\t// should still work the transport rather than do nothing.
|
||||
\t\t\tvar type = ( node.getAttribute( "type" ) || "text" ).toLowerCase();
|
||||
\t\t\treturn type !== "range" && type !== "checkbox" && type !== "radio" &&
|
||||
\t\t\t\ttype !== "button" && type !== "submit";
|
||||
\t\t}
|
||||
|
||||
\t\t/** Whether something on screen has a better claim to the spacebar.
|
||||
\t\t *
|
||||
\t\t * The mixer wins outright when it is up: it carries a transport of its
|
||||
\t\t * own, and it can be opened over the file sheet, so "something else is
|
||||
\t\t * also open" says nothing about what is in front. Otherwise any modal
|
||||
\t\t * takes it — the file sheet, export, settings, the spectrogram are
|
||||
\t\t * each their own screen with their own keyboard, and working the
|
||||
\t\t * transport from underneath one would be a surprise.
|
||||
\t\t *
|
||||
\t\t * Both markers have to agree on open. Closing does not always take the
|
||||
\t\t * .open class back off, and .modal without .open is display:none
|
||||
\t\t * however the hidden attribute reads. */
|
||||
\t\tfunction transportKeyBlocked() {
|
||||
\t\t\tif ( mixerOpen() ) {
|
||||
\t\t\t\treturn false;
|
||||
\t\t\t}
|
||||
\t\t\treturn document.querySelectorAll( ".modal.open:not([hidden])" ).length > 0;
|
||||
\t\t}
|
||||
|
||||
\t\tdocument.addEventListener( "keydown", function ( e ) {
|
||||
\t\t\t// "Spacebar" is what older WebKit reports, and this ships in a webview.
|
||||
\t\t\tif ( e.key !== " " && e.key !== "Spacebar" ) {
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\tif ( e.metaKey || e.ctrlKey || e.altKey ) {
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\tif ( typingInField( e.target ) || transportKeyBlocked() ) {
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\t// Taken whether or not a file is loaded: where the transport is the
|
||||
\t\t\t// thing on screen, the space belongs to it, and scrolling instead is
|
||||
\t\t\t// never what was meant by it.
|
||||
\t\t\te.preventDefault();
|
||||
\t\t\t// Holding the key must not toggle thirty times a second.
|
||||
\t\t\tif ( e.repeat || ! state.playback ) {
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}
|
||||
\t\t\ttogglePlayPause();
|
||||
\t\t\tsyncMixerTransport();
|
||||
\t\t} );
|
||||
|
||||
\t\tcontainer.addEventListener( "click", function ( e ) {
|
||||
\t\t\tif ( e.target.closest( "[data-bwfa-mixer-open]" ) ) {
|
||||
\t\t\t\topenMixer();
|
||||
\t\t\t\treturn;
|
||||
\t\t\t}""",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
PLAYER_PATCHES = [
|
||||
(
|
||||
"""\t\tfunction decodeFileToAudioBuffer( row ) {
|
||||
@@ -2489,6 +2585,25 @@ PLAYER_PATCHES = [
|
||||
]
|
||||
|
||||
APP_JS_PATCHES = [
|
||||
# ---- a failed file has to say why -----------------------------------
|
||||
#
|
||||
# This swallowed the reason entirely, and a bulk run that writes nothing
|
||||
# at all still ends with a tidy "Done" line — so the only signal that
|
||||
# forty files were untouched was a count most people read as progress.
|
||||
# The reason goes to the log (Cmd-Shift-L) with the file it belongs to.
|
||||
(
|
||||
"""\t\t\t\t} ).catch( function () {
|
||||
\t\t\t\t\tfailed++;
|
||||
\t\t\t\t} ).then( function () {
|
||||
\t\t\t\t\tnext( index + 1 );""",
|
||||
"""\t\t\t\t} ).catch( function ( err ) {
|
||||
\t\t\t\t\tfailed++;
|
||||
\t\t\t\t\tconsole.error( "BWF Analyser: bulk edit failed on " +
|
||||
\t\t\t\t\t\t( ( row.parsed && row.parsed.fileName ) || "a file" ) + " — " +
|
||||
\t\t\t\t\t\t( ( err && ( err.message || err.name ) ) || err ) );
|
||||
\t\t\t\t} ).then( function () {
|
||||
\t\t\t\t\tnext( index + 1 );""",
|
||||
),
|
||||
# ---- the confirmation has to be visible to be a confirmation --------
|
||||
#
|
||||
# This was window.confirm, which in this webview returns false without
|
||||
@@ -2858,14 +2973,12 @@ def guard(text, label, tag):
|
||||
|
||||
css = guard(read(SRC / "bwf-analyser.css"), "plugin css", "style")
|
||||
overrides_css = guard(read(BUILD / "overrides.css"), "overrides css", "style")
|
||||
shell_css = guard(read(BUILD / ("tauri-shell.css" if TAURI else "shell.css")), "shell css", "style")
|
||||
shell_css = guard(read(BUILD / "tauri-shell.css"), "shell css", "style")
|
||||
|
||||
# The icon, inlined. 256px covers the launch screen's 88px at any scale
|
||||
# factor, and keeps the payload to a few KB.
|
||||
launch_logo = ""
|
||||
if TAURI:
|
||||
icon = BUILD.parent / "mac-app" / "src-tauri" / "icons" / "128x128@2x.png"
|
||||
launch_logo = "data:image/png;base64," + base64.b64encode(icon.read_bytes()).decode("ascii")
|
||||
icon = BUILD.parent / "mac-app" / "src-tauri" / "icons" / "128x128@2x.png"
|
||||
launch_logo = "data:image/png;base64," + base64.b64encode(icon.read_bytes()).decode("ascii")
|
||||
body = read(BUILD / "body.html")
|
||||
l10n = json.loads(read(BUILD / "l10n.json"))
|
||||
jspdf = guard(read(SRC / "jspdf.umd.min.js"), "jspdf", "script")
|
||||
@@ -2911,18 +3024,16 @@ def apply_patches(source, patches, label):
|
||||
return source
|
||||
|
||||
|
||||
# Correctness fixes belong in every build; the UI reshuffles are app-only.
|
||||
parser_js = apply_patches(parser_js, PARSER_PATCHES, "parser js patch")
|
||||
app_js = apply_patches(app_js, SHARED_APP_JS_PATCHES, "shared app js patch")
|
||||
app_js = apply_patches(app_js, BASE_APP_JS_PATCHES, "base app js patch")
|
||||
app_js = apply_patches(app_js, MIXER_PATCHES, "mixer patch")
|
||||
if TAURI:
|
||||
app_js = apply_patches(app_js, APP_JS_PATCHES, "tauri app js patch")
|
||||
app_js = apply_patches(app_js, PLAYER_PATCHES, "player patch")
|
||||
app_js = apply_patches(app_js, SPECTRO_PATCHES, "spectrogram patch")
|
||||
shim_js = guard(read(BUILD / ("tauri-bridge.js" if TAURI else "shim.js")), "shim js", "script")
|
||||
app_js = apply_patches(app_js, TRANSPORT_KEY_PATCHES, "transport key patch")
|
||||
app_js = apply_patches(app_js, APP_JS_PATCHES, "app js patch")
|
||||
app_js = apply_patches(app_js, PLAYER_PATCHES, "player patch")
|
||||
app_js = apply_patches(app_js, SPECTRO_PATCHES, "spectrogram patch")
|
||||
shim_js = guard(read(BUILD / "tauri-bridge.js"), "shim js", "script")
|
||||
|
||||
if TAURI:
|
||||
replacements = [
|
||||
replacements = [
|
||||
# The editor is the only way in now, so the button reads as an opener
|
||||
# rather than as an alternative to something above it.
|
||||
(
|
||||
@@ -2967,30 +3078,30 @@ if TAURI:
|
||||
data-bwfa-player-export>Export</button>''',
|
||||
),
|
||||
]
|
||||
for old, new in replacements:
|
||||
if old not in body:
|
||||
sys.exit("tauri markup edit no longer matches: %.60s..." % old)
|
||||
body = body.replace(old, new)
|
||||
for old, new in replacements:
|
||||
if old not in body:
|
||||
sys.exit("tauri markup edit no longer matches: %.60s..." % old)
|
||||
body = body.replace(old, new)
|
||||
|
||||
# Bulk edit moves out of the results panel and into a modal of its own,
|
||||
# taking the export panel with it. Both end up after the detail modal,
|
||||
# inside the scoped app container so the stylesheet still reaches them.
|
||||
body, bulk_panel = cut(
|
||||
body,
|
||||
'<div class="bwfa-bulk-edit-panel" data-bwfa-bulk-edit-panel hidden>',
|
||||
'<button type="button" class="btn btn-primary" data-bwfa-bulk-edit-apply disabled>Apply</button>\n\t\t\t</div>\n\t\t</div>',
|
||||
"the bulk edit panel",
|
||||
)
|
||||
sheets = (
|
||||
sheet("bulk", "Bulk Edit", bulk_panel.strip(), "bwfa-sheet-wide") +
|
||||
sheet("export", "Export Files", EXPORT_PANEL_HTML) +
|
||||
sheet("result", "Export report", EXPORT_RESULT_HTML, "bwfa-sheet-report")
|
||||
)
|
||||
# Bulk edit moves out of the results panel and into a modal of its own,
|
||||
# taking the export panel with it. Both end up after the detail modal,
|
||||
# inside the scoped app container so the stylesheet still reaches them.
|
||||
body, bulk_panel = cut(
|
||||
body,
|
||||
'<div class="bwfa-bulk-edit-panel" data-bwfa-bulk-edit-panel hidden>',
|
||||
'<button type="button" class="btn btn-primary" data-bwfa-bulk-edit-apply disabled>Apply</button>\n\t\t\t</div>\n\t\t</div>',
|
||||
"the bulk edit panel",
|
||||
)
|
||||
sheets = (
|
||||
sheet("bulk", "Bulk Edit", bulk_panel.strip(), "bwfa-sheet-wide") +
|
||||
sheet("export", "Export Files", EXPORT_PANEL_HTML) +
|
||||
sheet("result", "Export report", EXPORT_RESULT_HTML, "bwfa-sheet-report")
|
||||
)
|
||||
|
||||
tail = "\t\t</div>\n\t</div>\n</div>\n"
|
||||
if body.count(tail) != 1:
|
||||
sys.exit("cannot place the modals: the end of body.html no longer matches")
|
||||
body = body.replace(tail, "\t\t</div>\n\t</div>\n\n" + sheets + "</div>\n")
|
||||
tail = "\t\t</div>\n\t</div>\n</div>\n"
|
||||
if body.count(tail) != 1:
|
||||
sys.exit("cannot place the modals: the end of body.html no longer matches")
|
||||
body = body.replace(tail, "\t\t</div>\n\t</div>\n\n" + sheets + "</div>\n")
|
||||
|
||||
l10n_js = "window.bwfaL10n = " + json.dumps(l10n, ensure_ascii=False, indent=1) + ";"
|
||||
|
||||
@@ -3016,7 +3127,7 @@ template = """<!DOCTYPE html>
|
||||
@@BODY@@
|
||||
</div>
|
||||
|
||||
@@FOOTER_BLOCK@@<script>
|
||||
<script>
|
||||
/* UI strings. Edit any value here to relabel the interface. */
|
||||
@@L10N@@
|
||||
</script>
|
||||
@@ -3054,14 +3165,6 @@ substitutions = {
|
||||
"app": app_js,
|
||||
"version": "1.5.1",
|
||||
"launch_logo": launch_logo,
|
||||
# The app's own window frame says what it is; a web page benefits from a
|
||||
# line explaining itself.
|
||||
"footer_block": "" if TAURI else (
|
||||
'<div class="page-foot">\n'
|
||||
"\tBWF Analyser 1.5.1 — standalone offline build. No network requests, no server, no\n"
|
||||
"\tdependencies to install. Everything you select stays on this computer.\n"
|
||||
"</div>\n\n"
|
||||
),
|
||||
}
|
||||
|
||||
html = template
|
||||
|
||||
@@ -1321,6 +1321,14 @@ html:has(.bwfa-scope[data-bwfa-theme="dark"]) {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
/* Each group is only as wide as what it holds. The framework gives
|
||||
.bwfa-modal-nav flex: 1 1 auto for the header it was written for, and
|
||||
in the footer that made both groups grow to fill the row: the forward
|
||||
arrow ended up stranded in the middle of the footer with its name, a
|
||||
long way from the buttons it belongs beside, and the margin-left: auto
|
||||
below could do nothing about it because there was no free space left
|
||||
for it to absorb. */
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Back sits hard left; forward sits against the buttons it precedes, with
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
/* ---------------------------------------------------------------
|
||||
Standalone page shell. The framework above is scoped to
|
||||
.bwfa-scope, so the page around it needs its own few rules.
|
||||
--------------------------------------------------------------- */
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #f4f4f5;
|
||||
color: #18181b;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 64px;
|
||||
}
|
||||
|
||||
.page-foot {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 40px;
|
||||
font-size: 13px;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.page-foot a {
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app {
|
||||
background: #fff;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .04);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page {
|
||||
padding: 12px 10px 48px;
|
||||
}
|
||||
|
||||
.bwfa-scope.bwfa-app {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shown only when the browser refuses the folder-write API on file:// */
|
||||
.bwfa-fs-warning {
|
||||
display: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #f0c36d;
|
||||
background: #fdf6e3;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: #6b4e00;
|
||||
}
|
||||
|
||||
.bwfa-fs-warning code {
|
||||
background: rgba(0, 0, 0, .06);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Standalone shim.
|
||||
*
|
||||
* The only thing this adds over the original plugin: a clearer message when
|
||||
* the browser refuses the File System Access API. Opening this page straight
|
||||
* off disk (file://) gives it an opaque origin in Chrome, and the folder
|
||||
* picker used for *writing* metadata rejects opaque origins with a
|
||||
* SecurityError. Reading, playback, CSV and PDF are unaffected — only the
|
||||
* "Edit Metadata in a Folder…" path needs the page served over http(s),
|
||||
* which locally means something like `python3 -m http.server` in this file's
|
||||
* folder and then visiting http://localhost:8000/.
|
||||
*
|
||||
* The app's own catch block runs before this one resolves its message, so
|
||||
* the warning is written on the next tick to make sure it lands last.
|
||||
*/
|
||||
( function () {
|
||||
"use strict";
|
||||
|
||||
if ( typeof window.showDirectoryPicker !== "function" ) {
|
||||
return;
|
||||
}
|
||||
|
||||
var original = window.showDirectoryPicker.bind( window );
|
||||
|
||||
window.showDirectoryPicker = function ( options ) {
|
||||
return original( options ).catch( function ( err ) {
|
||||
if ( err && ( err.name === "SecurityError" || err.name === "NotAllowedError" ) &&
|
||||
window.location.protocol === "file:" ) {
|
||||
window.setTimeout( function () {
|
||||
var warning = document.querySelector( "[data-bwfa-fs-warning]" );
|
||||
if ( warning ) {
|
||||
warning.style.display = "block";
|
||||
}
|
||||
var status = document.querySelector( "[data-bwfa-status]" );
|
||||
if ( status ) {
|
||||
status.textContent = "";
|
||||
}
|
||||
}, 0 );
|
||||
}
|
||||
throw err;
|
||||
} );
|
||||
};
|
||||
}() );
|
||||
@@ -57,7 +57,6 @@ body {
|
||||
the inputs inside them — it just isn't shown. */
|
||||
.bwfa-scope .bwfa-header,
|
||||
.bwfa-scope .bwfa-dropzone,
|
||||
.bwfa-scope .bwfa-fs-warning,
|
||||
.bwfa-scope .bwfa-edit-divider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ const { JSDOM, VirtualConsole } = require("jsdom");
|
||||
const { jsPDF } = require("jspdf");
|
||||
const { build } = require("./make-sample.js");
|
||||
|
||||
const INDEX = path.join(__dirname, "..", "index.html");
|
||||
const INDEX = path.join(__dirname, "..", "mac-app", "dist", "index.html");
|
||||
|
||||
const results = [];
|
||||
function check(name, fn) {
|
||||
|
||||
@@ -480,6 +480,14 @@ 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;
|
||||
@@ -1272,6 +1280,33 @@ function click(el) {
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
@@ -1338,6 +1373,46 @@ function click(el) {
|
||||
"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("<NAME>Boom</NAME>") !== -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 --- */
|
||||
|
||||
@@ -1397,6 +1472,60 @@ function click(el) {
|
||||
"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,
|
||||
@@ -1644,6 +1773,64 @@ function click(el) {
|
||||
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.
|
||||
|
||||
-626
@@ -1,626 +0,0 @@
|
||||
/**
|
||||
* Loads the built index.html in jsdom exactly as a browser would (scripts
|
||||
* executed in the page's own realm), feeds it synthetic BWF files through
|
||||
* the real file input, and asserts the table renders the right metadata.
|
||||
*
|
||||
* Run: npm i jsdom && node build/test.js
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const assert = require("assert");
|
||||
const { JSDOM, VirtualConsole } = require("jsdom");
|
||||
const { build } = require("./make-sample.js");
|
||||
|
||||
const INDEX = path.join(__dirname, "..", "index.html");
|
||||
const css = fs.readFileSync(INDEX, "utf8");
|
||||
|
||||
const createdBlobs = [];
|
||||
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(" ")));
|
||||
|
||||
const dom = new JSDOM(fs.readFileSync(INDEX, "utf8"), {
|
||||
runScripts: "dangerously",
|
||||
url: "file:///tmp/bwftest/index.html",
|
||||
pretendToBeVisual: true,
|
||||
virtualConsole,
|
||||
beforeParse(win) {
|
||||
// jsdom ships no canvas backend and no URL.createObjectURL; stub both
|
||||
// before any page script runs. The waveform paths are draw-only, and
|
||||
// blob URLs are only ever handed to a download anchor.
|
||||
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) });
|
||||
if (typeof target[prop] === "undefined") return () => {};
|
||||
return target[prop];
|
||||
},
|
||||
set: () => true,
|
||||
});
|
||||
win.HTMLCanvasElement.prototype.getContext = () => ctxStub;
|
||||
win.URL.createObjectURL = (blob) => {
|
||||
createdBlobs.push(blob);
|
||||
return "blob:stub-" + createdBlobs.length;
|
||||
};
|
||||
win.URL.revokeObjectURL = () => {};
|
||||
},
|
||||
});
|
||||
|
||||
const { window } = dom;
|
||||
|
||||
function fileFrom(name, buf) {
|
||||
const f = new window.File([new window.Uint8Array(buf)], name, { type: "audio/wav" });
|
||||
Object.defineProperty(f, "webkitRelativePath", { value: "Day14/" + name });
|
||||
return f;
|
||||
}
|
||||
|
||||
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, 50);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
const results = [];
|
||||
function check(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
results.push(["PASS", name]);
|
||||
} catch (e) {
|
||||
results.push(["FAIL", name + " — " + e.message]);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await new Promise((r) => window.addEventListener("load", r));
|
||||
|
||||
const doc = window.document;
|
||||
const container = doc.querySelector("[data-bwfa-app]");
|
||||
assert(container, "app container missing");
|
||||
|
||||
check("bwfaL10n is defined", () => assert.strictEqual(typeof window.bwfaL10n, "object"));
|
||||
check("parser exposed on window.BWFA", () => assert(window.BWFA && window.BWFA.RiffParser));
|
||||
check("jsPDF bundled and loaded", () => assert(window.jspdf && window.jspdf.jsPDF));
|
||||
check("dropzone got its localized text", () =>
|
||||
assert(doc.querySelector("[data-bwfa-dropzone-text]").textContent.trim().length > 0));
|
||||
check("table head rendered on init", () =>
|
||||
assert(doc.querySelector("[data-bwfa-table-head] th")));
|
||||
|
||||
check("detail metadata stays two pairs to a row", () => {
|
||||
// The plugin's own rule (auto-fill, minmax(180px, 1fr)) lands on three
|
||||
// columns at this modal width, which splits the dt/dd pairs across
|
||||
// rows. The override pins it to label, value, label, value.
|
||||
const probe = doc.createElement("dl");
|
||||
probe.className = "bwfa-meta-grid";
|
||||
container.appendChild(probe);
|
||||
const style = window.getComputedStyle(probe);
|
||||
assert.strictEqual((style.gridTemplateColumns.match(/minmax/g) || []).length, 4,
|
||||
"expected 4 tracks, got: " + style.gridTemplateColumns);
|
||||
probe.remove();
|
||||
});
|
||||
|
||||
// Feed three files through the real input, as a folder selection would.
|
||||
const files = [
|
||||
fileFrom("A001_12A_T1.wav", build({ scene: "12A", take: 1 })),
|
||||
fileFrom("A002_12A_T2.wav", build({ scene: "12A", take: 2, note: "plane overhead" })),
|
||||
fileFrom("A003_14B_T3.wav", build({ scene: "14B", take: 3, circled: true, tcSamples: 11 * 3600 * 48000 })),
|
||||
];
|
||||
const notWav = fileFrom("notes.txt", Buffer.from("ignore me"));
|
||||
|
||||
// jsdom has no webkitdirectory support, so the app correctly removes the
|
||||
// folder input and leaves the plain multi-file one — use that.
|
||||
const input = doc.querySelector("[data-bwfa-files-input]");
|
||||
const list = files.concat([notWav]);
|
||||
list.item = (i) => list[i];
|
||||
Object.defineProperty(input, "files", { value: list, configurable: true });
|
||||
input.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
|
||||
const status = doc.querySelector("[data-bwfa-status]");
|
||||
await waitFor(() => /Done/i.test(status.textContent), "parsing to finish");
|
||||
|
||||
const rowsOf = () => Array.from(doc.querySelectorAll("[data-bwfa-table-body] tr"));
|
||||
|
||||
check("three files parsed, non-wav skipped", () => {
|
||||
assert.strictEqual(rowsOf().length, 3, "got " + rowsOf().length + " rows");
|
||||
assert(/1 skipped/.test(status.textContent), status.textContent);
|
||||
});
|
||||
|
||||
const headers = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th")).map((th) =>
|
||||
th.textContent.replace(/[▲▼\s]+$/, "").trim());
|
||||
const cellsFor = (rowIndex) => Array.from(rowsOf()[rowIndex].querySelectorAll("td")).map((td) => td.textContent.trim());
|
||||
const col = (rowIndex, header) => cellsFor(rowIndex)[headers.indexOf(header)];
|
||||
|
||||
check("the table reports the frame rate", () => {
|
||||
assert(headers.includes("FPS"), "no FPS column: " + headers.join(" | "));
|
||||
assert(/25/.test(col(0, "FPS")), "FPS cell is empty: " + col(0, "FPS"));
|
||||
});
|
||||
|
||||
check("the report modal offers every field, all on", () => {
|
||||
container.querySelector("[data-bwfa-report-open]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
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");
|
||||
});
|
||||
|
||||
check("the columns cog sits in the table's first header cell", () => {
|
||||
const cells = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"));
|
||||
const cog = cells[0].querySelector("[data-bwfa-columns-open]");
|
||||
assert(cog, "no cog in the first header cell: " + cells[0].innerHTML);
|
||||
assert.strictEqual(cog.textContent.trim(), "", "it should be a glyph, not a word");
|
||||
assert(cog.getAttribute("aria-label"), "an icon-only button needs a label");
|
||||
// A smaller sibling of the eject button in the folder bar: round,
|
||||
// hairline border, glyph only.
|
||||
const style = window.getComputedStyle(cog);
|
||||
assert.strictEqual(style.borderRadius, "50%", "it isn't round: " + style.borderRadius);
|
||||
assert.strictEqual(style.width, style.height, "it isn't square, so it can't be a circle");
|
||||
assert.strictEqual(style.width, "22px", "it should be smaller than the 30px eject: " + style.width);
|
||||
// The border is declared with a custom property for its colour, which
|
||||
// jsdom won't substitute into a computed shorthand — so read the rule.
|
||||
const rules = Array.from(doc.querySelectorAll("style"))
|
||||
.map((el) => el.textContent).join("\n");
|
||||
const rule = rules.slice(rules.indexOf(".bwfa-columns-cog {"));
|
||||
assert(/border: 1px solid/.test(rule.slice(0, rule.indexOf("}"))),
|
||||
"no hairline border on the cog");
|
||||
// The dropdown it replaced is gone from view.
|
||||
assert.strictEqual(
|
||||
window.getComputedStyle(container.querySelector("[data-bwfa-columns-dropdown]")).display,
|
||||
"none", "the Columns dropdown is still in the toolbar");
|
||||
});
|
||||
|
||||
check("the cog opens a modal listing every column", () => {
|
||||
const modal = doc.querySelector("[data-bwfa-columns-modal]");
|
||||
assert.strictEqual(modal.hidden, true, "it was open before anything was clicked");
|
||||
doc.querySelector("[data-bwfa-columns-open]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
assert.strictEqual(modal.hidden, false, "the cog didn't open it");
|
||||
assert(modal.classList.contains("open"), "the framework needs .open to show a modal");
|
||||
const boxes = Array.from(modal.querySelectorAll("[data-bwfa-columns-menu] input[type=checkbox]"));
|
||||
assert(boxes.length >= 15, "only " + boxes.length + " columns offered");
|
||||
});
|
||||
|
||||
check("unticking a column takes it out of the table, then Done closes up", () => {
|
||||
const modal = doc.querySelector("[data-bwfa-columns-modal]");
|
||||
const labels = Array.from(modal.querySelectorAll("[data-bwfa-columns-menu] label"));
|
||||
const tape = labels.filter((row) => /^Tape\/Reel$/.test(row.textContent.trim()))[0];
|
||||
assert(tape, "no Tape column in the list: " + labels.map((l) => l.textContent.trim()).join(", "));
|
||||
const box = tape.querySelector("input");
|
||||
box.checked = false;
|
||||
box.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
|
||||
const headers = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
|
||||
.map((th) => th.textContent.replace(/[▲▼\s]+$/, "").trim());
|
||||
assert(!headers.includes("Tape/Reel"), "Tape is still a column: " + headers.join(" | "));
|
||||
// And the cog survived the redraw, since the head is rebuilt each time.
|
||||
assert(doc.querySelector("[data-bwfa-table-head] th [data-bwfa-columns-open]"),
|
||||
"the cog went missing when the table head was redrawn");
|
||||
|
||||
box.checked = true;
|
||||
box.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
doc.querySelector("[data-bwfa-columns-close]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
assert.strictEqual(modal.hidden, true, "Done didn't close it");
|
||||
});
|
||||
|
||||
check("one button in the toolbar, not three", () => {
|
||||
const visible = (selector) => {
|
||||
const el = container.querySelector(selector);
|
||||
assert(el, "no " + selector);
|
||||
return window.getComputedStyle(el).display !== "none";
|
||||
};
|
||||
assert(visible("[data-bwfa-report-open]"), "the Sound Report button is hidden");
|
||||
// And it looks like the rest of the row rather than announcing itself.
|
||||
const row = Array.from(container.querySelectorAll(".bwfa-export-actions .btn"))
|
||||
.filter((el) => window.getComputedStyle(el).display !== "none");
|
||||
const looks = new Set(row.map((el) => el.className.replace(/\s+/g, " ").trim()));
|
||||
assert.deepStrictEqual(Array.from(looks), ["btn btn-outline"],
|
||||
"the toolbar mixes button styles: " + Array.from(looks).join(" / "));
|
||||
// The originals stay in the DOM — the analyser wires the export
|
||||
// pipeline to them and the modal drives them — but out of sight.
|
||||
["[data-bwfa-export-csv]", "[data-bwfa-export-pdf]", "[data-bwfa-export-fields-toggle]"]
|
||||
.forEach((selector) => assert(!visible(selector), selector + " is still on show"));
|
||||
});
|
||||
|
||||
check("rates read in kHz everywhere, pull rates included", () => {
|
||||
// 47952 is the 0.1% pull, a rate in its own right — one decimal used to
|
||||
// round it to "48.0 kHz", which is a different rate.
|
||||
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(48000), "48 kHz");
|
||||
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(44100), "44.1 kHz");
|
||||
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(47952), "47.952 kHz");
|
||||
assert.strictEqual(window.BWFA.RiffParser.formatSampleRate(176400), "176.4 kHz");
|
||||
|
||||
// The table, and the file's own detail view.
|
||||
assert(/kHz/.test(col(0, "Sample Rate")), "table cell: " + col(0, "Sample Rate"));
|
||||
// The table and the detail view. The report's field list is exempt: it
|
||||
// names CSV columns, and that column really is in hertz.
|
||||
const onScreen = [doc.querySelector("[data-bwfa-table]"),
|
||||
doc.querySelector("[data-bwfa-modal-body]")]
|
||||
.map((el) => (el && el.textContent) || "").join(" ");
|
||||
assert(!/\bHz\b/.test(onScreen.replace(/kHz/g, "")),
|
||||
"something on screen still counts in hertz");
|
||||
});
|
||||
|
||||
check("and the sample-rate menus read the same way", () => {
|
||||
container.querySelector("[data-bwfa-bulk-edit-toggle]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const rows = Array.from(doc.querySelectorAll(".bwfa-bulk-edit-row"));
|
||||
const rateRow = rows.find((row) => /tc sample rate/i.test(row.textContent));
|
||||
assert(rateRow, "no TC Sample Rate field");
|
||||
const labels = Array.from(rateRow.querySelectorAll("option")).map((o) => o.textContent);
|
||||
assert(labels.includes("48 kHz"), "options read: " + labels.join(" | "));
|
||||
assert(labels.includes("47.952 kHz"), "the pull rate is missing: " + labels.join(" | "));
|
||||
assert(!labels.some((label) => / Hz$/.test(label)), "still in hertz: " + labels.join(" | "));
|
||||
doc.querySelector("[data-bwfa-bulk-edit-cancel]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
check("the two free-text fields sit side by side, the same size", () => {
|
||||
// They used to be laid out as leftovers: Note in whatever cell was
|
||||
// going spare, Description alone on a row below it.
|
||||
container.querySelector("[data-bwfa-bulk-edit-toggle]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const areas = Array.from(doc.querySelectorAll("[data-bwfa-bulk-edit-fields] textarea"));
|
||||
assert.strictEqual(areas.length, 2, "expected Note and Description, got " + areas.length);
|
||||
|
||||
const rows = areas.map((area) => area.closest(".bwfa-bulk-edit-row"));
|
||||
const styles = rows.map((row) => window.getComputedStyle(row));
|
||||
styles.forEach((style, i) => assert.strictEqual(style.gridColumn, "span 2",
|
||||
"textarea " + i + " isn't half a row: " + style.gridColumn));
|
||||
assert.strictEqual(window.getComputedStyle(rows[0]).gridColumnStart, "1",
|
||||
"the first of the pair doesn't start a fresh row, so they won't line up");
|
||||
|
||||
const heights = areas.map((area) => window.getComputedStyle(area).height);
|
||||
assert.strictEqual(new Set(heights).size, 1, "different heights: " + heights.join(" / "));
|
||||
|
||||
doc.querySelector("[data-bwfa-bulk-edit-cancel]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
check("the audio escape hatch is app-only", () => {
|
||||
// In a browser tab a reload loses the folder along with the audio, so
|
||||
// the button that reloads is not offered there.
|
||||
const action = container.querySelector("[data-bwfa-audio-reset-row]");
|
||||
assert(action, "the markup should be shared, only hidden");
|
||||
assert.strictEqual(window.getComputedStyle(action).display, "none",
|
||||
"the reload button is on show in the browser build");
|
||||
});
|
||||
|
||||
check("the report asks who it's for, and in what format", () => {
|
||||
["company", "project", "director", "mixer", "phone", "email", "note"].forEach((key) => {
|
||||
const input = container.querySelector('[data-bwfa-report-field="' + key + '"]');
|
||||
assert(input, "no field for " + key);
|
||||
assert.strictEqual(input.value, "", key + " starts filled in: " + input.value);
|
||||
});
|
||||
const format = container.querySelector("[data-bwfa-report-format]");
|
||||
assert(format, "no output format control");
|
||||
assert.deepStrictEqual(Array.from(format.options).map((o) => o.value), ["pdf", "csv"]);
|
||||
assert.strictEqual(format.value, "pdf", "the default should be the report, not the spreadsheet");
|
||||
});
|
||||
|
||||
container.querySelector("[data-bwfa-export-close]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
|
||||
check("scene/take read from iXML", () => {
|
||||
const scenes = rowsOf().map((_, i) => col(i, "Scene"));
|
||||
const takes = rowsOf().map((_, i) => col(i, "Take"));
|
||||
assert.deepStrictEqual(scenes.slice().sort(), ["12A", "12A", "14B"], scenes.join(","));
|
||||
assert.deepStrictEqual(takes.slice().sort(), ["1", "2", "3"], takes.join(","));
|
||||
});
|
||||
|
||||
check("start timecode reconstructed at 25fps", () => {
|
||||
const tcs = rowsOf().map((_, i) => col(i, "Start TC"));
|
||||
assert(tcs.includes("10:00:00:00"), "expected 10:00:00:00 among " + tcs.join(" | "));
|
||||
assert(tcs.includes("11:00:00:00"), "expected 11:00:00:00 among " + tcs.join(" | "));
|
||||
});
|
||||
|
||||
check("format fields read from fmt chunk", () => {
|
||||
assert(/48/.test(col(0, "Sample Rate")), col(0, "Sample Rate"));
|
||||
assert.strictEqual(col(0, "Bit Depth"), "24-bit");
|
||||
assert.strictEqual(col(0, "Channels"), "2");
|
||||
});
|
||||
|
||||
check("circled take flagged", () => {
|
||||
const circled = rowsOf().map((_, i) => col(i, "Circled"));
|
||||
assert.strictEqual(circled.filter((v) => /yes/i.test(v)).length, 1, circled.join(","));
|
||||
});
|
||||
|
||||
check("duration computed from data chunk", () => {
|
||||
const d = col(0, "Duration");
|
||||
assert(/00:00:01/.test(d), d);
|
||||
});
|
||||
|
||||
// Search filter
|
||||
const search = doc.querySelector("[data-bwfa-search]");
|
||||
search.value = "14B";
|
||||
search.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
await waitFor(() => rowsOf().length === 1, "filter to apply", 5000);
|
||||
check("search filters rows", () => assert.strictEqual(rowsOf().length, 1));
|
||||
search.value = "";
|
||||
search.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
await waitFor(() => rowsOf().length === 3, "filter to clear", 5000);
|
||||
|
||||
// Sorting
|
||||
const sceneHeader = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th"))
|
||||
.find((th) => /Scene/.test(th.textContent));
|
||||
const clickable = sceneHeader.querySelector("button") || sceneHeader;
|
||||
clickable.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
check("sorting by a column works", () => {
|
||||
const scenes = rowsOf().map((_, i) => col(i, "Scene"));
|
||||
const sorted = scenes.slice().sort();
|
||||
const reversed = sorted.slice().reverse();
|
||||
assert(
|
||||
JSON.stringify(scenes) === JSON.stringify(sorted) ||
|
||||
JSON.stringify(scenes) === JSON.stringify(reversed),
|
||||
scenes.join(",")
|
||||
);
|
||||
});
|
||||
|
||||
// Details modal (Track Names live here and in the CSV, not in the table).
|
||||
const detailsBtn = Array.from(rowsOf()[0].querySelectorAll("button"))
|
||||
.find((b) => /^(details|edit)$/i.test(b.textContent.trim()));
|
||||
assert(detailsBtn, "no Edit button in the row");
|
||||
detailsBtn.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const modal = doc.querySelector("[data-bwfa-modal]");
|
||||
check("details modal opens with metadata sections", () => {
|
||||
assert.strictEqual(modal.hidden, false, "modal still hidden");
|
||||
const text = doc.querySelector("[data-bwfa-modal-body]").textContent;
|
||||
assert(/iXML/i.test(text), "no iXML section");
|
||||
assert(/Broadcast Extension|bext/i.test(text), "no bext section");
|
||||
assert(/Sound Devices 833/.test(text), "originator missing");
|
||||
assert(/slate/.test(text), "cue label missing");
|
||||
assert(/BWF Analyser test harness/.test(text), "RIFF INFO missing");
|
||||
// Track names are fields now, not labels, so they live in values
|
||||
// rather than in the modal's text.
|
||||
const trackNames = Array.from(modal.querySelectorAll("[data-bwfa-track-name]"))
|
||||
.map((input) => input.value);
|
||||
assert(trackNames.indexOf("Boom") !== -1 && trackNames.indexOf("Lav Anna") !== -1,
|
||||
"iXML track names missing: " + trackNames.join(", "));
|
||||
});
|
||||
doc.querySelector("[data-bwfa-modal-close]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
|
||||
// CSV export — the blob is captured by the stubbed createObjectURL rather
|
||||
// than actually downloaded.
|
||||
window.HTMLAnchorElement.prototype.click = function () {};
|
||||
const isCsv = (b) => b && b.type && b.type.indexOf("csv") !== -1;
|
||||
doc.querySelector("[data-bwfa-export-csv]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => createdBlobs.filter(isCsv)[0], "csv blob", 5000);
|
||||
const csvBlob = createdBlobs.filter(isCsv)[0];
|
||||
// Blob.text() decodes as UTF-8, which swallows the BOM — check the raw
|
||||
// bytes for it separately.
|
||||
const csvBytes = new Uint8Array(await csvBlob.arrayBuffer());
|
||||
const csvText = await csvBlob.text();
|
||||
check("but the spreadsheet still counts in hertz, as it should", () => {
|
||||
// kHz is how a rate is read on screen and in a printed report. A CSV of
|
||||
// metadata is machine-read, and the convention there — Wave Agent, the
|
||||
// recorders' own exports, the file itself — is the integer in hertz.
|
||||
const head = csvText.replace(/^\ufeff/, "").split("\r\n")[0].split(",");
|
||||
const row = csvText.replace(/^\ufeff/, "").split("\r\n")[1].split(",");
|
||||
const at = head.indexOf("Sample Rate (Hz)");
|
||||
assert(at !== -1, "the column was relabelled: " + head.join(" | "));
|
||||
assert.strictEqual(row[at], "48000", "the cell says " + row[at]);
|
||||
});
|
||||
|
||||
check("CSV export contains header and all rows", () => {
|
||||
assert.deepStrictEqual(Array.from(csvBytes.slice(0, 3)), [0xef, 0xbb, 0xbf], "missing UTF-8 BOM");
|
||||
assert(/\r\n/.test(csvText), "expected CRLF line endings (RFC 4180)");
|
||||
assert(/Scene/.test(csvText.split("\n")[0]), "no header row");
|
||||
["A001_12A_T1.wav", "A002_12A_T2.wav", "A003_14B_T3.wav"].forEach((n) =>
|
||||
assert(csvText.indexOf(n) !== -1, "missing " + n));
|
||||
assert(/12A/.test(csvText) && /14B/.test(csvText), "scenes missing");
|
||||
assert(/Boom/.test(csvText) && /Lav Anna/.test(csvText), "track names missing from CSV");
|
||||
assert(/Track Names/.test(csvText.split("\n")[0]), "Track Names column missing");
|
||||
});
|
||||
// The same CSV, but asked for through the report modal with the production
|
||||
// details filled in.
|
||||
const details = {
|
||||
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",
|
||||
};
|
||||
createdBlobs.length = 0;
|
||||
doc.querySelector("[data-bwfa-report-open]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
Object.keys(details).forEach((key) => {
|
||||
doc.querySelector('[data-bwfa-report-field="' + key + '"]').value = details[key];
|
||||
});
|
||||
doc.querySelector("[data-bwfa-report-format]").value = "csv";
|
||||
doc.querySelector("[data-bwfa-report-create]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => createdBlobs.filter(isCsv)[0], "report csv blob", 5000);
|
||||
const reportCsv = await createdBlobs.filter(isCsv)[0].text();
|
||||
|
||||
check("Create Report writes the format that was chosen", () => {
|
||||
assert.strictEqual(createdBlobs.filter(isCsv).length, 1,
|
||||
"expected one CSV, got " + createdBlobs.filter(isCsv).length);
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-export-modal]").hidden, true,
|
||||
"the modal stayed open over the save panel");
|
||||
});
|
||||
|
||||
check("the CSV leads with the production details, then a blank line", () => {
|
||||
const lines = reportCsv.replace(/^\ufeff/, "").split("\r\n");
|
||||
assert.strictEqual(lines[0], 'Production company,Acme Films', "first line: " + lines[0]);
|
||||
assert.strictEqual(lines[1], 'Project / show,The Long Weekend', "second line: " + lines[1]);
|
||||
assert.strictEqual(lines[7], "", "no blank line between the details and the table: " + lines[7]);
|
||||
assert(/^File Name|Scene/.test(lines[8]) || /Scene/.test(lines[8]),
|
||||
"the table header should follow the blank line: " + lines[8]);
|
||||
assert(reportCsv.indexOf("A001_12A_T1.wav") !== -1, "the rows went missing");
|
||||
});
|
||||
|
||||
check("a detail with a comma in it is still one field", () => {
|
||||
assert(reportCsv.indexOf('"Day 4, ext. night"') !== -1,
|
||||
"the note wasn't quoted: " + reportCsv.split("\r\n").slice(0, 8).join(" / "));
|
||||
});
|
||||
|
||||
// PDF export — jsPDF copies its API onto each instance, so the only
|
||||
// reliable interception point is the constructor itself.
|
||||
let pdfBytes = null;
|
||||
const RealJsPDF = window.jspdf.jsPDF;
|
||||
window.jspdf.jsPDF = function (options) {
|
||||
const instance = new RealJsPDF(options);
|
||||
instance.save = function () {
|
||||
pdfBytes = this.output("arraybuffer");
|
||||
return this;
|
||||
};
|
||||
return instance;
|
||||
};
|
||||
doc.querySelector("[data-bwfa-export-pdf]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => pdfBytes, "pdf generation", 15000);
|
||||
window.jspdf.jsPDF = RealJsPDF;
|
||||
check("PDF export produces a valid PDF", () => {
|
||||
const head = Buffer.from(new Uint8Array(pdfBytes).slice(0, 5)).toString("latin1");
|
||||
assert.strictEqual(head, "%PDF-", "not a PDF: " + head);
|
||||
assert(pdfBytes.byteLength > 1000, "suspiciously small: " + pdfBytes.byteLength);
|
||||
});
|
||||
|
||||
// Metadata writer: round-trip an edit through the parser.
|
||||
const writer = window.BWFA && window.BWFA.MetadataWriter;
|
||||
check("metadata writer is available", () => assert(writer, "BWFA.MetadataWriter missing"));
|
||||
|
||||
// Clear hides the results panel and re-shows the empty state.
|
||||
const resultsEl = doc.querySelector("[data-bwfa-results]");
|
||||
const emptyEl = doc.querySelector("[data-bwfa-empty]");
|
||||
doc.querySelector("[data-bwfa-clear]").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await waitFor(() => resultsEl.hidden === true, "clear", 5000);
|
||||
check("clear resets back to the empty state", () => {
|
||||
assert.strictEqual(resultsEl.hidden, true, "results still visible");
|
||||
assert.strictEqual(emptyEl.hidden, false, "empty state not shown");
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-clear]").disabled, true, "clear still enabled");
|
||||
assert.strictEqual(doc.querySelector("[data-bwfa-status]").textContent.trim(), "", "status not cleared");
|
||||
});
|
||||
|
||||
// A feature wired into one build only looks exactly like a broken feature:
|
||||
// the control is there, pressing it does nothing. So check the knob in the
|
||||
// plain browser page as well, where there is no native engine.
|
||||
check("the mixer knob works in the browser build too", () => {
|
||||
const knob = doc.querySelector("[data-bwfa-mixer-open]");
|
||||
assert(knob, "no mixer knob on the page");
|
||||
knob.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
const modal = doc.querySelector("[data-bwfa-mixer]");
|
||||
assert.strictEqual(modal.hidden, false, "the knob is dead: the modal stayed shut");
|
||||
// Unhidden is not visible. This framework keeps .modal at display:none
|
||||
// until it also has .open, so a test that only checks the attribute
|
||||
// passes while the user sees nothing at all — which is what happened.
|
||||
assert(modal.classList.contains("open"),
|
||||
"the modal is unhidden but has no .open class, so it is still display:none");
|
||||
const spectro = doc.querySelector("[data-bwfa-spectro]");
|
||||
assert(spectro, "no spectrogram modal to compare against");
|
||||
assert.strictEqual(window.getComputedStyle(modal).display,
|
||||
window.getComputedStyle(spectro).display === "none"
|
||||
? window.getComputedStyle(modal).display : "flex",
|
||||
"the mixer isn't shown the way the other modals are");
|
||||
const said = doc.querySelector("[data-bwfa-mixer-strips]").textContent;
|
||||
assert(said.trim().length > 0, "the mixer opened empty and said nothing");
|
||||
doc.querySelector("[data-bwfa-mixer-close]")
|
||||
.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
assert.strictEqual(modal.hidden, true, "it wouldn't close");
|
||||
assert(!modal.classList.contains("open"), "closing left the .open class behind");
|
||||
});
|
||||
|
||||
check("the player's round buttons cannot be squashed", () => {
|
||||
// The bug this exists for: a flex item with a set width still shrinks,
|
||||
// and because the icon inside overflows, the button goes on looking
|
||||
// perfectly normal while its hit area collapses to nothing. jsdom has
|
||||
// no layout, so no amount of clicking in a test can catch it — read
|
||||
// the rule instead.
|
||||
const probe = doc.createElement("button");
|
||||
probe.className = "bwfa-round-btn";
|
||||
container.appendChild(probe);
|
||||
const style = window.getComputedStyle(probe);
|
||||
const shrink = style.flexShrink || style.getPropertyValue("flex-shrink");
|
||||
probe.remove();
|
||||
assert.strictEqual(String(shrink), "0",
|
||||
"a round button can shrink (flex-shrink: " + shrink + "), which kills its hit area");
|
||||
assert(/\.bwfa-round-btn\s*\{[^}]*flex:\s*none/.test(css),
|
||||
"no flex: none on .bwfa-round-btn in the shipped stylesheet");
|
||||
});
|
||||
|
||||
check("the app carries a log you can read without developer tools", () => {
|
||||
// The app ships without an inspector, so "what does the console say"
|
||||
// has to be answerable from inside the window.
|
||||
assert(window.BWFA_DIAG, "no diagnostics on the page");
|
||||
window.console.error("a deliberate error, for the log");
|
||||
const captured = window.BWFA_DIAG.lines().join("\n");
|
||||
assert(/a deliberate error, for the log/.test(captured),
|
||||
"the log didn't catch a console error");
|
||||
assert(/page built/.test(window.BWFA_DIAG.lines().join("\n")) ||
|
||||
/ERROR/.test(captured), "nothing useful in the log");
|
||||
// And it measures the controls, which is what tells us a visible
|
||||
// button has no hit area — the failure that started all this.
|
||||
window.BWFA_DIAG.probe();
|
||||
const measured = window.BWFA_DIAG.lines().join("\n");
|
||||
assert(/PROBE.*data-bwfa-mixer-open/.test(measured),
|
||||
"the probe didn't measure the mixer knob");
|
||||
// jsdom reports every box as zero, so don't assert on the numbers —
|
||||
// only that the knob and the modal were both found on the page.
|
||||
assert(!/data-bwfa-mixer-open\] MISSING/.test(measured),
|
||||
"the probe says the knob isn't on the page at all");
|
||||
assert(/mixer modal in the page: yes/.test(measured),
|
||||
"the probe says the mixer modal is missing");
|
||||
consoleErrors.length = 0;
|
||||
});
|
||||
|
||||
check("mute and solo are dead centre in their circles", () => {
|
||||
// .chip pads 0.85rem left against 0.5rem right, which reads fine under
|
||||
// a word and visibly lopsided under a single letter.
|
||||
const probe = doc.createElement("button");
|
||||
probe.className = "chip bwfa-mixer-mute";
|
||||
container.appendChild(probe);
|
||||
const style = window.getComputedStyle(probe);
|
||||
const pad = [style.paddingLeft, style.paddingRight, style.paddingTop, style.paddingBottom];
|
||||
probe.remove();
|
||||
assert(pad.every((v) => parseFloat(v || 0) === 0),
|
||||
"the letter is pushed off centre by padding: " + pad.join(" "));
|
||||
assert(/\.bwfa-mixer-mute[^{]*\{[^}]*justify-content:\s*center/.test(css),
|
||||
"nothing centres the letter horizontally");
|
||||
assert(/\.bwfa-mixer-mute[^{]*\{[^}]*align-items:\s*center/.test(css),
|
||||
"nothing centres the letter vertically");
|
||||
});
|
||||
|
||||
check("the mixer borrows the app's accent, not the system's", () => {
|
||||
// The faders came up macOS blue because they asked for a --accent
|
||||
// variable this app has never defined, so every one of them fell
|
||||
// through to the hardcoded fallback.
|
||||
assert(!/accent-color:\s*var\(\s*--accent\b/.test(css),
|
||||
"something still asks for --accent, which this app doesn't define");
|
||||
assert(!/#0a84ff/i.test(css), "a hardcoded system blue is still in the stylesheet");
|
||||
assert(/accent-color:\s*var\(\s*--color-action\s*\)/.test(css),
|
||||
"the faders don't use the app's own action colour");
|
||||
});
|
||||
|
||||
check("the page stamps when it was built", () => {
|
||||
// So "is the app running the page I just built?" stops being a matter
|
||||
// of opinion. The native build embeds this page at compile time.
|
||||
assert(/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(window.BWFA_BUILD || ""),
|
||||
"no build stamp on the page: " + window.BWFA_BUILD);
|
||||
assert.strictEqual(container.getAttribute("data-bwfa-build"), window.BWFA_BUILD,
|
||||
"the stamp on the page and the stamp on the app disagree");
|
||||
});
|
||||
|
||||
check("no script errors on the page", () =>
|
||||
assert.strictEqual(consoleErrors.length, 0, consoleErrors.join(" | ")));
|
||||
|
||||
console.log("");
|
||||
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");
|
||||
process.exit(failed ? 1 : 0);
|
||||
})().catch((e) => {
|
||||
console.error("harness error:", e);
|
||||
console.error(consoleErrors.join("\n"));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -159,7 +159,7 @@ step "Assembling the app's page"
|
||||
# button visible, nothing behind it — which is a miserable thing to debug. So
|
||||
# build the page here, from source, every time.
|
||||
if command -v python3 >/dev/null 2>&1 && [ -f "../build/build.py" ]; then
|
||||
( cd .. && python3 build/build.py --tauri ) || fail "assembling dist/index.html failed. The error is above."
|
||||
( cd .. && python3 build/build.py ) || fail "assembling dist/index.html failed. The error is above."
|
||||
else
|
||||
warn "python3 or build/build.py not found — using the dist/index.html already on disk."
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user