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:
2026-08-18 00:14:36 +08:00
parent 2d0b7fe8b5
commit 16c3e6e103
13 changed files with 409 additions and 860 deletions
+187
View File
@@ -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.