aboutsummaryrefslogtreecommitdiff
path: root/lib/project/tests
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-12 15:25:06 -0400
committerhistoria <historiavg@proton.me>2026-09-12 15:25:06 -0400
commit464edbc4fd658728f06d3a7d98340aa28ff3e3ec (patch)
treea182f62ef0365f2c2143f88436de78a03d752367 /lib/project/tests
parent1bbe59f78be9a8355bb0bee2bc8ab14ca42ac71c (diff)
downloadproducer-464edbc4fd658728f06d3a7d98340aa28ff3e3ec.tar.gz
feat: slop web gui
Diffstat (limited to 'lib/project/tests')
-rw-r--r--lib/project/tests/gui_state.test.mjs31
-rw-r--r--lib/project/tests/test_gui.py260
-rw-r--r--lib/project/tests/test_gui_assets.py46
-rw-r--r--lib/project/tests/test_gui_browser.py257
-rw-r--r--lib/project/tests/test_gui_browser_races.py210
-rw-r--r--lib/project/tests/test_gui_jobs.py124
-rw-r--r--lib/project/tests/test_gui_launcher.py113
-rw-r--r--lib/project/tests/test_pauses.py200
8 files changed, 1241 insertions, 0 deletions
diff --git a/lib/project/tests/gui_state.test.mjs b/lib/project/tests/gui_state.test.mjs
new file mode 100644
index 0000000..10775eb
--- /dev/null
+++ b/lib/project/tests/gui_state.test.mjs
@@ -0,0 +1,31 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {parseTime, formatTime, selection, stable, matches, monitoringGain, absolutePath} from '../src/producer/web/state.js';
+
+test('exact excerpt times work beyond four hours without wrapping', () => {
+ assert.equal(parseTime('05:12:03.125'), 18723.125);
+ assert.equal(formatTime(18723.125), '05:12:03.125');
+ assert.equal(parseTime('14400'), 14400);
+ assert.equal(formatTime(59.9999), '00:01:00');
+ for (const value of ['1:60:00', '-1', 'Infinity', '1e3', '', '00:00:60']) assert.throws(() => parseTime(value));
+ assert.deepEqual(selection('03:59:30', '04:00:00', 14400), {start: 14370, duration: 30});
+ for (const [start, end] of [['0', '121'], ['2', '1'], ['0', '0'], ['14399', '14401']]) assert.throws(() => selection(start, end, 14400));
+});
+
+test('preview identity includes source, exact selection and every requested setting', () => {
+ const snapshot = {path: 'take.wav', input_id: 'identity', start: 10, duration: 30, settings: {mp3: true, trim_silence: true, denoiser: 'none'}};
+ assert.ok(matches(snapshot, structuredClone(snapshot)));
+ assert.ok(matches(snapshot, {...snapshot, settings: {denoiser: 'none', trim_silence: true, mp3: true}}));
+ for (const change of [{path: 'other.wav'}, {input_id: 'changed'}, {start: 11}, {duration: 29}, {settings: {...snapshot.settings, trim_silence: false}}]) assert.equal(matches(snapshot, {...snapshot, ...change}), false);
+ assert.equal(matches(snapshot, null), false);
+ assert.equal(stable({b: 2, a: 1}), stable({a: 1, b: 2}));
+});
+
+test('monitoring is attenuation only and independent of export settings', () => {
+ assert.equal(monitoringGain(-6, false), 1);
+ assert.equal(monitoringGain(6, true), 1);
+ assert.equal(monitoringGain(NaN, true), 1);
+ assert.ok(Math.abs(monitoringGain(-6, true) - 0.501187) < 0.000001);
+ assert.equal(absolutePath('/recordings', 'delivery/master.wav'), '/recordings/delivery/master.wav');
+ assert.equal(absolutePath('/', './master.wav'), '/master.wav');
+});
diff --git a/lib/project/tests/test_gui.py b/lib/project/tests/test_gui.py
new file mode 100644
index 0000000..c9e5285
--- /dev/null
+++ b/lib/project/tests/test_gui.py
@@ -0,0 +1,260 @@
+"""Optional GUI API, origin/root boundary, snapshots, and real sound previews."""
+from dataclasses import asdict
+import hashlib
+from pathlib import Path
+import struct
+import time
+
+import numpy as np
+import pytest
+import soundfile as sf
+
+pytest.importorskip("fastapi")
+from fastapi.testclient import TestClient
+
+from producer.config import Settings
+from producer.gui import create_app
+from producer import gui_jobs
+
+TOKEN = "test-session-credential"
+AUTH = {"Authorization": f"Bearer {TOKEN}"}
+URL = "http://127.0.0.1:8765"
+
+
+@pytest.fixture
+def client(tmp_path):
+ with TestClient(create_app(tmp_path, 8765, TOKEN), base_url=URL, headers=AUTH) as client:
+ yield client
+
+
+def wait(client, job_id):
+ deadline = time.monotonic() + 30
+ while time.monotonic() < deadline:
+ job = client.get(f"/api/jobs/{job_id}").json()
+ if job["status"] in ("succeeded", "failed", "cancelled"):
+ return job
+ time.sleep(0.02)
+ pytest.fail("Job failed to terminate")
+
+
+def submit(client, path, kind, **kwargs):
+ info = client.get("/api/input", params={"path": path.name})
+ assert info.status_code == 200, info.text
+ response = client.post("/api/jobs", json={"kind": kind, "path": path.name,
+ "input_id": info.json()["input_id"], **kwargs})
+ assert response.status_code == 202, response.text
+ return response.json()
+
+
+def test_security_credentials_origins_hosts_and_headers(tmp_path):
+ with TestClient(create_app(tmp_path, 8765, TOKEN), base_url=URL) as client:
+ assert client.get("/").status_code == 200
+ assert client.get("/api/schema").status_code == 401
+ assert client.get(f"/api/schema?token={TOKEN}").status_code == 401
+ assert client.get("/api/schema", headers={"Cookie": f"token={TOKEN}"}).status_code == 401
+ for headers in [AUTH | {"Host": "evil.example:8765"},
+ AUTH | {"Host": "localhost:8765"},
+ AUTH | {"Origin": "https://evil.example"},
+ AUTH | {"Origin": "null"},
+ AUTH | {"Origin": "http://127.0.0.1:9999"},
+ AUTH | {"Sec-Fetch-Site": "cross-site"}]:
+ assert client.get("/api/schema", headers=headers).status_code == 403
+ assert client.post("/api/jobs", json={}, headers=headers).status_code == 403
+ response = client.get("/api/schema", headers=AUTH | {"Origin": URL})
+ assert response.status_code == 200
+ assert response.headers["Cache-Control"] == "no-store"
+ assert response.headers["Referrer-Policy"] == "no-referrer"
+ assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"]
+ assert "Access-Control-Allow-Origin" not in response.headers
+ assert TOKEN not in response.text
+ assert client.get("/openapi.json").status_code == 404
+ assert client.post("/api/resolve", content=b"x" * 65537, headers=AUTH).status_code == 413
+ assert client.post("/api/resolve", content="{", headers=AUTH).status_code == 400
+
+
+def test_root_browser_symlinks_traversal_and_no_arbitrary_download(client, tmp_path, wav):
+ source = wav()
+ outside = tmp_path.parent / "outside.wav"
+ outside.write_bytes(b"not accessible")
+ (tmp_path / "escape.wav").symlink_to(outside)
+ (tmp_path / "escape-dir").symlink_to(tmp_path.parent, target_is_directory=True)
+ (tmp_path / "inside.wav").symlink_to(source)
+ (tmp_path / "note.txt").write_text("not exposed")
+ (tmp_path / "subdir").mkdir()
+ listing = client.get("/api/files").json()
+ assert listing["parent"] is None
+ assert {item["name"] for item in listing["entries"]} == {"input.wav", "inside.wav", "subdir"}
+ for path in ("../outside.wav", str(outside), "escape.wav", "escape-dir", "subdir/../../outside.wav"):
+ assert client.get("/api/input", params={"path": path}).status_code == 400
+ assert client.get("/api/files", params={"path": ".."}).status_code == 400
+ assert client.get("/api/files", params={"limit": 201}).status_code == 400
+ assert len(client.get("/api/files", params={"limit": 1}).json()["entries"]) == 1
+ assert client.get("/api/files", params={"limit": 1}).json()["next_offset"] == 1
+ assert client.get("/assets/../gui.py").status_code == 404
+ assert client.get("/assets/%2e%2e/gui.py").status_code == 404
+ assert client.get("/api/results/missing/original.wav").status_code == 404
+
+
+def test_schema_complete_strict_resolution_and_preset_roundtrip(client, bypass):
+ schema = client.get("/api/schema").json()
+ assert {item["name"] for item in schema["fields"]} == set(asdict(Settings()))
+ assert all(item["group"] and item["help"] for item in schema["fields"])
+ assert schema["settings"] == asdict(Settings())
+ for value in ({"unknown": 1}, {"normalize": "false"}, {"target_lufs": -100},
+ {"normalize": True, "limiter": False}, {"sample_rate": 1234}):
+ assert client.post("/api/resolve", json={"settings": value}).status_code == 400
+ response = client.post("/api/presets/download", json={"settings": asdict(bypass)})
+ assert response.status_code == 200
+ assert "attachment" in response.headers["Content-Disposition"]
+ imported = client.post("/api/presets/import", json={"toml": response.text})
+ assert imported.json()["settings"] == asdict(bypass)
+ assert client.post("/api/presets/import", json={"toml": "no_such_setting = 2"}).status_code == 400
+ assert client.post("/api/presets/import", json={"toml": "["}).status_code == 400
+
+
+def test_config_precedence_and_startup_snapshot(tmp_path):
+ config = tmp_path / "config.toml"
+ config.write_text('profile = "narrator"\ntarget_lufs = -25\n')
+ app = create_app(tmp_path, 8765, TOKEN, config=config, profile="radio", overrides={"target_lufs": -22})
+ config.write_text("this file changed after startup")
+ with TestClient(app, base_url=URL, headers=AUTH) as client:
+ settings = client.get("/api/schema").json()["settings"]
+ assert settings["profile"] == "radio" and settings["compressor_ratio"] == 3.5
+ assert settings["target_lufs"] == -22
+ resolved = client.post("/api/resolve", json={"settings": {"target_lufs": -18, "profile": "natural"}})
+ assert resolved.json()["settings"]["target_lufs"] == -18
+ assert resolved.json()["settings"]["compressor_ratio"] == 2
+
+
+def test_scan_fixed_bins_cache_and_stale_identity(client, wav, monkeypatch):
+ source = wav()
+ original = source.read_bytes()
+ first = wait(client, submit(client, source, "scan")["id"])
+ assert first["status"] == "succeeded", first
+ result = first["result"]
+ assert len(result["min"]) == len(result["max"]) == 2048
+ assert max(result["max"]) == pytest.approx(0.15, abs=1e-6)
+ monkeypatch.setattr(gui_jobs, "waveform", lambda *args: pytest.fail("Expected repeated-result cache"))
+ second = wait(client, submit(client, source, "scan")["id"])
+ assert second["cached"] and second["result"] == result
+ assert source.read_bytes() == original
+ old_identity = first["snapshot"]["input_id"]
+ with source.open("ab") as handle:
+ handle.write(b"changed")
+ response = client.post("/api/jobs", json={"kind": "scan", "path": source.name, "input_id": old_identity})
+ assert response.status_code == 400
+ assert "changed" in response.text
+
+
+def test_real_preview_context_exact_crop_bypass_and_export_trim(client, wav, bypass, ffmpeg):
+ t = np.arange(48000 * 8) / 48000
+ samples = 0.15 * np.sin(2 * np.pi * 440 * t)
+ source = wav(samples)
+ before = hashlib.sha256(source.read_bytes()).hexdigest()
+ requested = asdict(bypass) | {"trim_silence": True, "mp3": True}
+ queued = submit(client, source, "preview", settings=requested, start=2, duration=3)
+ preview = wait(client, queued["id"])
+ assert preview["status"] == "succeeded", preview
+ result = preview["result"]
+ assert preview["snapshot"]["settings"] == requested
+ assert result["requested_settings"] == requested
+ assert result["effective_settings"] == requested | {"trim_silence": False, "mp3": False}
+ assert result["normalization_mode"] == "disabled"
+ assert result["context_start"] == 0 and result["context_end"] == 8
+ assert result["approximate"] is True
+ assert "Pacing edits are not previewed" in result["notice"]
+ import io
+ originals = client.get(result["audio"]["original"])
+ processed = client.get(result["audio"]["processed"])
+ for response in (originals, processed):
+ assert response.status_code == 200
+ info = sf.info(io.BytesIO(response.content))
+ assert info.frames == 3 * 48000
+ assert info.subtype == "PCM_16"
+ original_audio, _ = sf.read(io.BytesIO(originals.content))
+ processed_audio, _ = sf.read(io.BytesIO(processed.content))
+ np.testing.assert_allclose(original_audio, processed_audio, atol=1 / 32768)
+ np.testing.assert_allclose(original_audio, samples[2 * 48000:5 * 48000], atol=1 / 32768)
+ assert result["monitoring"]["available"]
+ assert result["monitoring"]["original_gain_db"] <= 0
+ assert result["monitoring"]["processed_gain_db"] <= 0
+ repeated = wait(client, submit(client, source, "preview", settings=requested, start=2, duration=3)["id"])
+ assert repeated["cached"] and repeated["result"]["result_id"] == result["result_id"]
+ changed = wait(client, submit(client, source, "preview", settings=requested | {"highpass": True}, start=2, duration=3)["id"])
+ assert not changed["cached"] and changed["result"]["result_id"] != result["result_id"]
+ for _ in range(2):
+ exported = wait(client, submit(client, source, "export", settings=requested)["id"])
+ assert exported["status"] == "succeeded", exported
+ assert exported["result"]["settings"] == requested
+ assert len(exported["result"]["paths"]) == 3
+ assert "not auditioned" in exported["result"]["notice"]
+ for path in exported["result"]["paths"]:
+ assert (source.parent / path).is_file()
+ assert len(list(source.parent.glob("producer-export-*"))) == 2
+ assert hashlib.sha256(source.read_bytes()).hexdigest() == before
+ # A cached audio URL cannot read stale results after the recording changes.
+ with source.open("ab") as handle:
+ handle.write(b"changed")
+ assert client.get(result["audio"]["original"]).status_code == 400
+
+
+def test_preview_bounds_settings_errors_and_unsupported_input(client, wav):
+ source = wav()
+ for start, duration in ((-1, 1), (0, 121), (0, 0), (2, 2), (True, 1)):
+ info = client.get("/api/input", params={"path": source.name}).json()
+ response = client.post("/api/jobs", json={"kind": "preview", "path": source.name,
+ "input_id": info["input_id"], "start": start, "duration": duration})
+ assert response.status_code == 400
+ stereo = wav(np.zeros((20, 2)), name="stereo.wav")
+ assert "Stereo" in client.get("/api/input", params={"path": stereo.name}).json()["detail"]
+ surround = wav(np.zeros((20, 3)), name="surround.wav")
+ assert client.get("/api/input", params={"path": surround.name}).status_code == 400
+ source.write_bytes(b"not a wav")
+ response = client.get("/api/input", params={"path": source.name})
+ assert response.status_code == 400 and "Unsupported" in response.text
+
+
+def test_no_total_duration_cap_and_seek_to_end_of_four_hour_input(tmp_path, progress):
+ # Sparse mono PCM16 WAV. No real recordings touched and no huge allocation.
+ source = tmp_path / "four-hours.wav"
+ sr, seconds = 8000, 4 * 60 * 60
+ data_bytes = sr * seconds * 2
+ header = (b"RIFF" + struct.pack("<I", 36 + data_bytes) + b"WAVEfmt "
+ + struct.pack("<IHHIIHH", 16, 1, 1, sr, sr * 2, 2, 16)
+ + b"data" + struct.pack("<I", data_bytes))
+ with source.open("wb") as handle:
+ handle.write(header)
+ handle.truncate(44 + data_bytes)
+ assert gui_jobs.input_info(source)["duration"] == seconds
+ target = tmp_path / "end.wav"
+ gui_jobs.copy_range(source, target, seconds - 2, 2, progress)
+ assert sf.info(target).frames == 2 * sr
+
+
+def test_waveform_buffers_bounded_and_scan_cancellable(wav, monkeypatch):
+ source = wav()
+ actual = sf.SoundFile
+ reads = []
+ class CheckedFile:
+ def __init__(self, *args, **kwargs):
+ self.source = actual(*args, **kwargs)
+ def __enter__(self):
+ return self
+ def __exit__(self, *args):
+ self.source.close()
+ def __len__(self):
+ return len(self.source)
+ def __getattr__(self, name):
+ return getattr(self.source, name)
+ def blocks(self, **kwargs):
+ assert kwargs["blocksize"] <= 65536
+ for block in self.source.blocks(**kwargs):
+ reads.append(len(block))
+ yield block
+ monkeypatch.setattr(gui_jobs.sf, "SoundFile", CheckedFile)
+ def cancel(stage, completed, total):
+ if completed:
+ raise gui_jobs.Cancelled()
+ with pytest.raises(gui_jobs.Cancelled):
+ gui_jobs.waveform(source, cancel)
+ assert reads == [65536]
diff --git a/lib/project/tests/test_gui_assets.py b/lib/project/tests/test_gui_assets.py
new file mode 100644
index 0000000..b7f949a
--- /dev/null
+++ b/lib/project/tests/test_gui_assets.py
@@ -0,0 +1,46 @@
+"""Packaged, CSP-compatible frontend assets do not require a bundler or CDN."""
+from html.parser import HTMLParser
+
+import pytest
+
+pytest.importorskip("fastapi")
+from fastapi.testclient import TestClient
+
+from producer.gui import create_app
+
+
+class AssetReferences(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.scripts = []
+ self.stylesheets = []
+ self.inline_attributes = []
+
+ def handle_starttag(self, tag, attrs):
+ attrs = dict(attrs)
+ self.inline_attributes.extend(name for name in attrs if name == "style" or name.startswith("on"))
+ if tag == "script":
+ self.scripts.append(attrs.get("src"))
+ if tag == "link" and attrs.get("rel") == "stylesheet":
+ self.stylesheets.append(attrs.get("href"))
+
+
+def test_gui_packaged_external_assets_and_guided_workflow(tmp_path):
+ with TestClient(create_app(tmp_path, 8765, "test-token"), base_url="http://127.0.0.1:8765") as client:
+ page = client.get("/")
+ assert page.status_code == 200
+ parser = AssetReferences()
+ parser.feed(page.text)
+ assert parser.scripts == ["/assets/app.js"]
+ assert parser.stylesheets == ["/assets/style.css"]
+ assert not parser.inline_attributes
+ assert "unsafe-inline" not in page.headers["Content-Security-Policy"]
+ assert "Approximate sound comparison, not an exact export preview." in page.text
+ for name in ("Choose your recording", "Try a short passage", "Compare the sound", "Export the full recording"):
+ assert name in page.text
+ for path in parser.scripts + parser.stylesheets + ["/assets/state.js"]:
+ response = client.get(path)
+ assert response.status_code == 200, path
+ assert response.headers["Cache-Control"] == "no-store"
+ assert len(response.content) > 100
+ assert client.get("/api/schema").status_code == 401
diff --git a/lib/project/tests/test_gui_browser.py b/lib/project/tests/test_gui_browser.py
new file mode 100644
index 0000000..1e38964
--- /dev/null
+++ b/lib/project/tests/test_gui_browser.py
@@ -0,0 +1,257 @@
+"""Opt-in real Chromium/loopback smoke; no AI models or recording uploads.
+
+Install Playwright externally, then run:
+PRODUCER_BROWSER_TESTS=1 python -m pytest tests/test_gui_browser.py -q
+Screenshots go to PRODUCER_BROWSER_ARTIFACTS (default /tmp/producer-browser-smoke).
+The long overview case uses mocked fixed-size scan metadata, not 4-hour AI inference.
+"""
+from dataclasses import asdict
+import hashlib
+import json
+import os
+import re
+from pathlib import Path
+import socket
+import threading
+import time
+
+import numpy as np
+import pytest
+
+pytestmark = pytest.mark.skipif(os.environ.get("PRODUCER_BROWSER_TESTS") != "1", reason="Opt-in Playwright browser smoke")
+
+
+@pytest.fixture
+def studio(tmp_path, wav, bypass, ffmpeg, monkeypatch):
+ pytest.importorskip("playwright.sync_api")
+ import uvicorn
+ from producer.gui import create_app
+ from producer import gui_jobs
+
+ source = wav(0.15 * np.sin(2 * np.pi * 440 * np.arange(48000 * 8) / 48000), name="Narration.wav")
+ wav(np.zeros((100, 2)), name="Stereo.wav")
+ (tmp_path / "delivery").mkdir()
+ (tmp_path / "subfolder").mkdir()
+ control = {"hold": False, "started": threading.Event(), "release": threading.Event()}
+ actual = gui_jobs.render_preview
+
+ def controlled_preview(*args):
+ if control["hold"]:
+ control["started"].set()
+ assert control["release"].wait(15), "Browser test did not release held preview"
+ control["hold"] = False
+ return actual(*args)
+
+ monkeypatch.setattr(gui_jobs, "render_preview", controlled_preview)
+ token = "browser-test-private-credential"
+ sock = socket.socket()
+ sock.bind(("127.0.0.1", 0))
+ sock.listen(128)
+ port = sock.getsockname()[1]
+ app = create_app(tmp_path, port, token, overrides=asdict(bypass))
+ server = uvicorn.Server(uvicorn.Config(app, log_level="error", access_log=False, proxy_headers=False))
+ thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True)
+ thread.start()
+ deadline = time.monotonic() + 10
+ while not server.started and time.monotonic() < deadline:
+ time.sleep(0.02)
+ assert server.started
+ try:
+ yield {"url": f"http://127.0.0.1:{port}", "token": token, "root": tmp_path, "source": source, "control": control}
+ finally:
+ control["release"].set()
+ server.should_exit = True
+ thread.join(15)
+ sock.close()
+ assert not thread.is_alive()
+
+
+def test_gui_browser_real_workflow(studio):
+ from playwright.sync_api import sync_playwright, expect
+
+ screenshots = Path(os.environ.get("PRODUCER_BROWSER_ARTIFACTS", "/tmp/producer-browser-smoke"))
+ screenshots.mkdir(parents=True, exist_ok=True)
+ before = hashlib.sha256(studio["source"].read_bytes()).hexdigest()
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
+ context = browser.new_context(viewport={"width": 1360, "height": 1000}, accept_downloads=True)
+ page = context.new_page()
+ errors, console_errors, media_requests = [], [], []
+ page.on("pageerror", lambda exc: errors.append(str(exc)))
+ page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
+ page.on("request", lambda req: media_requests.append(req.url) if "/api/results/" in req.url else None)
+ page.goto(studio["url"] + "/#token=" + studio["token"])
+ expect(page.locator("#workspace")).not_to_have_attribute("disabled", "")
+ assert "#" not in page.url
+ assert studio["token"] not in page.locator("body").inner_text()
+ assert page.locator("[data-setting]").count() == 47
+ expect(page.locator("#setting-denoiser")).to_have_value("none")
+ expect(page.locator("#folder-up")).to_be_disabled()
+ # Unsupported input errors are human-readable, not raw HTML.
+ page.get_by_role("button", name=re.compile(r"WAV\s*Stereo.wav")).click()
+ expect(page.locator("#error")).to_be_visible()
+ expect(page.locator("#error-text")).to_contain_text("Stereo")
+ page.locator("#dismiss-error").click()
+ page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click()
+ expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=15000)
+ expect(page.locator("#input-info")).to_contain_text("00:00:08")
+ # Both drag selection and keyboard-accessible exact inputs.
+ page.locator("#waveform").scroll_into_view_if_needed()
+ box = page.locator("#waveform").bounding_box()
+ page.mouse.move(box["x"] + box["width"] * .25, box["y"] + 80)
+ page.mouse.down()
+ page.mouse.move(box["x"] + box["width"] * .65, box["y"] + 80, steps=5)
+ page.mouse.up()
+ assert page.locator("#selection-start").input_value() != "00:00:00"
+ page.locator("#selection-start").fill("00:00:02")
+ page.locator("#selection-end").fill("00:00:05")
+ page.locator("#zoom-selection").click()
+ page.locator("#zoom-full").click()
+ # Explicit render, requested pacing+MP3 preserved in snapshot but bypassed internally.
+ page.locator("#setting-trim_silence").check()
+ page.locator("#setting-mp3").check()
+ page.locator("#render-preview").click()
+ expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000)
+ assert len(media_requests) == 2
+ page.locator("#compare").scroll_into_view_if_needed()
+ page.locator("#play").click()
+ expect(page.locator("#play")).to_have_text("Pause")
+ page.wait_for_timeout(350)
+ position = page.locator("#audio-original").evaluate("audio => audio.currentTime")
+ page.locator("#listen-processed").click()
+ expect(page.locator("#listen-processed")).to_have_attribute("aria-pressed", "true")
+ assert page.locator("#audio-processed").evaluate("audio => audio.currentTime") >= position - .15
+ assert page.locator("#audio-original").evaluate("audio => audio.paused")
+ assert page.locator("#audio-processed").evaluate("audio => audio.loop")
+ page.locator("#match").check()
+ expect(page.locator("#monitoring-note")).to_contain_text("Monitoring only")
+ page.locator("#play").click()
+ expect(page.locator("#meters")).to_contain_text("not a full-export measurement")
+ page.locator("#comparison-name").fill("Gentle <snapshot>")
+ page.locator("#save-comparison").click()
+ expect(page.locator("#comparisons")).to_contain_text("Gentle <snapshot>")
+ assert page.locator("#comparisons snapshot").count() == 0
+ page.locator("#presets-panel summary").click()
+ page.locator("#preset-name").fill("My sound")
+ page.locator("#save-preset").click()
+ expect(page.locator("#presets")).to_contain_text("My sound")
+ with page.expect_download() as downloaded:
+ page.locator("#download-preset").click()
+ toml_path = screenshots / "roundtrip-preset.toml"
+ downloaded.value.save_as(toml_path)
+ assert "trim_silence = true" in toml_path.read_text()
+ assert studio["token"] not in toml_path.read_text()
+ page.locator("#setting-denoise_strength").fill("0.4")
+ expect(page.locator("#comparison-status")).to_contain_text("changed since preview")
+ page.locator("#import-preset").set_input_files(toml_path)
+ expect(page.locator("#setting-denoise_strength")).to_have_value("0.85")
+ # The saved copy cannot change when controls are edited later.
+ page.locator("#setting-denoise_strength").fill("0.5")
+ saved = page.evaluate("JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k => k.startsWith('producer-studio-v1:'))))")
+ assert saved["comparisons"][0]["snapshot"]["settings"]["denoise_strength"] == .85
+ assert saved["presets"][0]["settings"]["denoise_strength"] == .85
+ assert studio["token"] not in json.dumps(saved)
+ # Delay real preview completion. Editing after submission must never publish it as current.
+ studio["control"]["hold"] = True
+ page.locator("#render-preview").click()
+ assert studio["control"]["started"].wait(10)
+ old_source = page.locator("#comparison-source").inner_text()
+ page.locator("#setting-denoise_strength").fill("0.6")
+ studio["control"]["release"].set()
+ expect(page.locator("#jobs")).to_contain_text("Older snapshot; not loaded", timeout=30000)
+ assert page.locator("#comparison-source").inner_text() == old_source
+ assert len(media_requests) == 2
+ # Restore is an explicit render and restores the original immutable requested settings.
+ page.locator("#comparisons button", has_text="Restore & render").click()
+ expect(page.locator("#setting-denoise_strength")).to_have_value("0.85")
+ expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000)
+ # Cross-field errors do not silently enable the limiter.
+ page.locator("#setting-normalize").check()
+ page.locator("#render-preview").click()
+ expect(page.locator("#error-text")).to_contain_text("limiter")
+ assert not page.locator("#setting-limiter").is_checked()
+ page.locator("#dismiss-error").click()
+ page.locator("#setting-normalize").uncheck()
+ page.locator("#output-dir").fill("delivery")
+ preview_requests = len(media_requests)
+ page.locator("#prepare-export").click()
+ expect(page.locator("#export-dialog")).to_be_visible()
+ expect(page.locator("#export-pacing")).to_contain_text("NOT auditioned")
+ expect(page.locator("#export-summary")).to_contain_text("whole original 00:00:08")
+ page.locator("#confirm-export").click()
+ expect(page.locator("#export-results")).to_contain_text("Full recording exported", timeout=30000)
+ outputs = list((studio["root"] / "delivery").glob("producer-export-*"))
+ assert len(outputs) == 1
+ assert (outputs[0] / "master.wav").is_file() and (outputs[0] / "master.mp3").is_file()
+ assert (outputs[0] / "logs/master.report.json").is_file()
+ import soundfile as sf
+ assert sf.info(outputs[0] / "master.wav").duration == pytest.approx(8, abs=.01)
+ assert len(media_requests) == preview_requests, "Full exports must not be fetched into browser blobs"
+ page.locator("#prepare-export").click()
+ page.locator("#confirm-export").click()
+ expect(page.locator("#export-results .export-result")).to_have_count(2, timeout=30000)
+ assert len(list((studio["root"] / "delivery").glob("producer-export-*"))) == 2
+ assert hashlib.sha256(studio["source"].read_bytes()).hexdigest() == before
+ page.locator("#compare").scroll_into_view_if_needed()
+ page.screenshot(path=str(screenshots / "desktop-comparison.png"), full_page=True)
+ page.locator("#compare").screenshot(path=str(screenshots / "comparison-panel.png"))
+ # Reload retains per-tab authorization and local snapshots, not misleading current audio.
+ page.reload()
+ expect(page.locator("#workspace")).not_to_have_attribute("disabled", "")
+ expect(page.locator("#comparisons")).to_contain_text("Gentle <snapshot>")
+ expect(page.locator("#play")).to_be_disabled()
+ expect(page.locator("#export-results .export-result")).to_have_count(2, timeout=15000)
+ page.set_viewport_size({"width": 390, "height": 844})
+ page.screenshot(path=str(screenshots / "mobile-studio.png"), full_page=True)
+ page.evaluate("window.scrollTo(0, 0)")
+ page.screenshot(path=str(screenshots / "mobile-entry.png"))
+ assert page.evaluate("document.documentElement.scrollWidth <= innerWidth")
+ # A fresh tab without the private fragment cannot read API data.
+ unauth = context.new_page()
+ unauth.goto(studio["url"])
+ expect(unauth.locator("#error-text")).to_contain_text("private URL")
+ expect(unauth.locator("#workspace")).to_have_attribute("disabled", "")
+ unauth.close()
+ # Simulate server restart credential expiry and verify recovery guidance.
+ page.route("**/api/jobs", lambda route: route.fulfill(status=401, json={"detail": "expired"}))
+ expect(page.locator("#workspace")).to_have_attribute("disabled", "", timeout=10000)
+ expect(page.locator("#connection")).to_contain_text("Session expired")
+ assert errors == []
+ # Expected HTTP errors for stereo, invalid constraint and the expiry test are allowed.
+ assert not [msg for msg in console_errors if "Content Security Policy" in msg or "Refused" in msg]
+ browser.close()
+
+
+def test_gui_browser_long_overview(studio):
+ from playwright.sync_api import sync_playwright, expect
+
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
+ page = browser.new_page(viewport={"width": 1200, "height": 900})
+ requests, errors = [], []
+ page.on("request", lambda request: requests.append(request.url))
+ page.on("pageerror", lambda exc: errors.append(str(exc)))
+ page.goto(studio["url"] + "/#token=" + studio["token"])
+ expect(page.locator("#workspace")).not_to_have_attribute("disabled", "")
+ # Four-hour metadata plus fixed-size peaks: browser never receives full recording samples.
+ info = {"path": "Narration.wav", "input_id": "long-identity", "frames": 14400 * 8000,
+ "duration": 14400, "sample_rate": 8000, "channels": 1, "subtype": "PCM_16"}
+ result = {"input_id": "long-identity", "duration": 14400, "frames": info["frames"], "sample_rate": 8000,
+ "bins": 2048, "min": [-.3] * 2048, "max": [.4] * 2048}
+ job = {"id": "mock-scan", "kind": "scan", "snapshot": {"path": "Narration.wav", "input_id": "long-identity"},
+ "status": "succeeded", "progress": {"stage": "Done", "completed": 1, "total": 1},
+ "created": time.time(), "result": result, "error": None, "cancel_requested": False, "cached": False}
+ page.route("**/api/input?*", lambda route: route.fulfill(json=info))
+ page.route("**/api/jobs", lambda route: route.fulfill(json=job if route.request.method == "POST" else {"jobs": [job]}))
+ page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click()
+ expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000)
+ expect(page.locator("#wave-end")).to_have_text("04:00:00")
+ page.locator("#selection-start").fill("03:59:30")
+ page.locator("#selection-end").fill("04:00:00")
+ expect(page.locator("#selection-note")).to_contain_text("30 seconds selected")
+ page.locator("#zoom-selection").click()
+ expect(page.locator("#wave-start")).to_have_text("03:59:15")
+ assert len(json.dumps(result)) < 50000
+ assert not [url for url in requests if "/api/results/" in url]
+ assert errors == []
+ browser.close()
diff --git a/lib/project/tests/test_gui_browser_races.py b/lib/project/tests/test_gui_browser_races.py
new file mode 100644
index 0000000..fab60ba
--- /dev/null
+++ b/lib/project/tests/test_gui_browser_races.py
@@ -0,0 +1,210 @@
+"""Additional opt-in browser race/cancellation coverage; uses the real API fixture."""
+import os
+import re
+
+import pytest
+
+from test_gui_browser import studio # noqa: F401 -- shared real loopback fixture
+
+pytestmark = pytest.mark.skipif(os.environ.get("PRODUCER_BROWSER_TESTS") != "1", reason="Opt-in Playwright browser smoke")
+
+
+def test_gui_browser_cancellation_is_not_pretend(studio):
+ from playwright.sync_api import sync_playwright, expect
+
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
+ page = browser.new_page()
+ page.goto(studio["url"] + "/#token=" + studio["token"])
+ page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click()
+ expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000)
+ studio["control"]["hold"] = True
+ page.locator("#render-preview").click()
+ assert studio["control"]["started"].wait(10)
+ expect(page.locator("#jobs .job").filter(has_text="running")).to_have_count(1, timeout=10000)
+ page.locator("#setting-denoise_strength").fill("0.4")
+ page.locator("#render-preview").click()
+ queued = page.locator("#jobs .job").filter(has_text="queued")
+ expect(queued).to_have_count(1)
+ queued.get_by_role("button", name="Cancel", exact=True).click()
+ expect(page.locator("#jobs .job").filter(has_text="cancelled")).to_have_count(1)
+ running = page.locator("#jobs .job").filter(has_text="running")
+ running.get_by_role("button", name="Cancel", exact=True).click()
+ expect(page.locator("#jobs")).to_contain_text("Cancelling…")
+ assert page.locator("#jobs .job").filter(has_text="cancelled").count() == 1
+ studio["control"]["release"].set()
+ expect(page.locator("#jobs .job").filter(has_text="cancelled")).to_have_count(2, timeout=10000)
+ expect(page.locator("#play")).to_be_disabled()
+ browser.close()
+
+
+def test_gui_browser_late_audio_is_aborted_and_urls_are_released(studio):
+ from playwright.sync_api import sync_playwright, expect
+
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
+ page = browser.new_page()
+ page.add_init_script("""window.objectURLs = {created: [], revoked: []};
+ const create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL);
+ URL.createObjectURL = blob => { const url = create(blob); objectURLs.created.push(url); return url; };
+ URL.revokeObjectURL = url => { objectURLs.revoked.push(url); revoke(url); };""")
+ page.goto(studio["url"] + "/#token=" + studio["token"])
+ page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click()
+ expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000)
+ page.locator("#selection-start").fill("1")
+ page.locator("#selection-end").fill("4")
+ page.locator("#render-preview").click()
+ expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000)
+ first_source = page.locator("#comparison-source").inner_text()
+ first_urls = page.evaluate("objectURLs.created.slice()")
+ assert len(first_urls) == 2
+ held = []
+
+ def hold(route):
+ response = route.fetch()
+ held.append((route, response))
+
+ page.route("**/api/results/**", hold)
+ page.locator("#selection-start").fill("2")
+ page.locator("#render-preview").click()
+ for _ in range(200):
+ if len(held) == 2:
+ break
+ page.wait_for_timeout(50)
+ assert len(held) == 2
+ expect(page.locator("#comparison-status")).to_contain_text("Loading")
+ page.locator("#selection-start").fill("3")
+ for route, response in held:
+ route.fulfill(response=response)
+ page.wait_for_timeout(500)
+ assert page.locator("#comparison-source").inner_text() == first_source
+ expect(page.locator("#comparison-status")).to_contain_text("changed since preview")
+ assert page.evaluate("objectURLs.created.length") == 2
+ page.unroute("**/api/results/**", hold)
+ page.locator("#render-preview").click()
+ expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000)
+ assert page.evaluate("objectURLs.created.length") == 4
+ assert page.evaluate("objectURLs.revoked") == first_urls
+ browser.close()
+
+
+def test_gui_browser_nonzero_monitoring_gain_is_playback_only(studio):
+ from playwright.sync_api import sync_playwright, expect
+
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
+ page = browser.new_page()
+ page.add_init_script("""window.monitorGains = [];
+ const createGain = AudioContext.prototype.createGain;
+ AudioContext.prototype.createGain = function() {
+ const gain = createGain.call(this); monitorGains.push(gain); return gain;
+ };""")
+ page.goto(studio["url"] + "/#token=" + studio["token"])
+ page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click()
+ expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000)
+ page.locator("#advanced-settings summary").click()
+ # Real FFmpeg leveling makes a measurable difference without any AI runtime.
+ page.locator("#setting-leveling").check()
+ page.locator("#setting-level_target_db").fill("-12")
+ page.locator("#render-preview").click()
+ expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000)
+ requested = page.locator("#preview-settings").text_content()
+ meters = page.locator("#meters").inner_text()
+ page.locator("#match").check()
+ expect(page.locator("#monitoring-note")).to_contain_text("Monitoring only")
+ gains = page.evaluate("monitorGains.map(node => node.gain.value)")
+ assert len(gains) == 2
+ assert all(0 < gain <= 1 for gain in gains)
+ assert min(gains) < .9, "A nonzero measured loudness difference should attenuate one playback side"
+ assert page.locator("#preview-settings").text_content() == requested
+ assert page.locator("#meters").inner_text() == meters
+ page.locator("#prepare-export").click()
+ expect(page.locator("#export-dialog")).to_be_visible()
+ frozen_export = page.locator("#export-settings").text_content()
+ assert '"level_target_db": -12' in frozen_export
+ assert "original_gain_db" not in frozen_export and "processed_gain_db" not in frozen_export
+ assert "monitoring" not in frozen_export
+ page.locator("#cancel-export").click()
+ page.locator("#match").uncheck()
+ assert page.evaluate("monitorGains.map(node => node.gain.value)") == [1, 1]
+ browser.close()
+
+
+@pytest.mark.parametrize("kind", ["scan", "preview"])
+def test_delayed_job_list_preserves_new_submission_intent(studio, kind):
+ from playwright.sync_api import sync_playwright, expect
+
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
+ page = browser.new_page()
+ page.goto(studio["url"] + "/#token=" + studio["token"])
+ if kind == "preview":
+ page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click()
+ expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000)
+ held = []
+
+ def hold_one_list(route):
+ if route.request.method == "GET" and not held:
+ held.append((route, route.fetch()))
+ else:
+ route.continue_()
+
+ page.route("**/api/jobs", hold_one_list)
+ for _ in range(100):
+ if held:
+ break
+ page.wait_for_timeout(50)
+ assert held, "Expected an in-flight list captured before the new submission"
+ with page.expect_response(lambda response: response.request.method == "POST"
+ and response.url.endswith("/api/jobs")) as posted:
+ if kind == "scan":
+ page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click()
+ else:
+ page.locator("#render-preview").click()
+ assert posted.value.status == 202
+ expect(page.locator("#jobs .job").filter(has_text=kind.upper())).to_have_count(1)
+ route, response = held[0]
+ route.fulfill(response=response)
+ if kind == "scan":
+ expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000)
+ else:
+ expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000)
+ browser.close()
+
+
+@pytest.mark.parametrize("interruption", ["switch", "edit", "expire"])
+def test_delayed_audio_resume_does_not_start_superseded_playback(studio, interruption):
+ from playwright.sync_api import sync_playwright, expect
+
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
+ page = browser.new_page()
+ page.add_init_script("""window.releaseAudioResume = null;
+ const resume = AudioContext.prototype.resume;
+ AudioContext.prototype.resume = function() {
+ return new Promise(resolve => {
+ window.releaseAudioResume = () => { resume.call(this).then(resolve); };
+ });
+ };""")
+ page.goto(studio["url"] + "/#token=" + studio["token"])
+ page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click()
+ expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000)
+ page.locator("#render-preview").click()
+ expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000)
+ page.locator("#play").click()
+ page.wait_for_function("typeof releaseAudioResume === 'function'")
+ if interruption == "switch":
+ page.locator("#listen-processed").click()
+ expect(page.locator("#listen-processed")).to_have_attribute("aria-pressed", "true")
+ elif interruption == "edit":
+ page.locator("#setting-denoise_strength").fill("0.4")
+ else:
+ page.route("**/api/jobs", lambda route: route.fulfill(
+ status=401, content_type="application/json", body='{"detail":"Expired"}'))
+ expect(page.locator("#workspace")).to_have_attribute("disabled", "", timeout=10000)
+ page.evaluate("releaseAudioResume()")
+ # Let both the resume and any erroneously scheduled play promise settle.
+ page.wait_for_timeout(300)
+ assert page.locator("#audio-original").evaluate("audio => audio.paused")
+ assert page.locator("#audio-processed").evaluate("audio => audio.paused")
+ browser.close()
diff --git a/lib/project/tests/test_gui_jobs.py b/lib/project/tests/test_gui_jobs.py
new file mode 100644
index 0000000..6116a72
--- /dev/null
+++ b/lib/project/tests/test_gui_jobs.py
@@ -0,0 +1,124 @@
+"""Bounded single-worker scheduling and cancellation through real process cleanup."""
+import os
+import sys
+import threading
+import time
+
+import pytest
+
+from producer import gui_jobs, setup
+from producer.gui import rooted
+
+
+def terminal(jobs, job_id):
+ deadline = time.monotonic() + 10
+ while time.monotonic() < deadline:
+ job = jobs.get(job_id)
+ if job["status"] in ("succeeded", "failed", "cancelled"):
+ return job
+ time.sleep(0.01)
+ pytest.fail("Job failed to finish")
+
+
+def test_single_worker_bounded_queue_and_running_process_cancellation(wav, tmp_path, monkeypatch):
+ source = wav()
+ started = threading.Event()
+ pid_file = tmp_path / "child.pid"
+ calls = []
+ def slow_scan(path, progress):
+ calls.append(path)
+ if len(calls) == 1:
+ started.set()
+ setup.run_process([sys.executable, "-c",
+ f"import os,time;open({str(pid_file)!r},'w').write(str(os.getpid()));time.sleep(60)"],
+ "Waiting for worker", progress)
+ return {"bins": 2048}
+ monkeypatch.setattr(gui_jobs, "waveform", slow_scan)
+ jobs = gui_jobs.Jobs(tmp_path, lambda value: rooted(tmp_path, value))
+ snapshot = {"path": source.name, "input_id": gui_jobs.identity(source)}
+ try:
+ first = jobs.submit("scan", snapshot)
+ assert started.wait(5)
+ # Public snapshots cannot mutate the immutable in-flight job.
+ first["snapshot"]["path"] = "missing.wav"
+ queued = [jobs.submit("scan", snapshot) for _ in range(3)]
+ with pytest.raises(gui_jobs.Busy):
+ jobs.submit("scan", snapshot)
+ assert len(calls) == 1
+ assert jobs.cancel(queued[0]["id"])["status"] == "cancelled"
+ deadline = time.monotonic() + 5
+ while not pid_file.exists() and time.monotonic() < deadline:
+ time.sleep(0.01)
+ pid = int(pid_file.read_text())
+ jobs.cancel(first["id"])
+ assert terminal(jobs, first["id"])["status"] == "cancelled"
+ with pytest.raises(ProcessLookupError):
+ os.kill(pid, 0)
+ assert terminal(jobs, queued[1]["id"])["status"] == "succeeded"
+ assert terminal(jobs, queued[2]["id"])["cached"]
+ assert len(calls) == 2
+ finally:
+ jobs.close()
+ assert not jobs.work.exists()
+
+
+def test_history_and_result_cache_are_bounded_and_evicted_media_expires(wav, tmp_path, monkeypatch):
+ source = wav()
+ monkeypatch.setattr(gui_jobs, "MAX_HISTORY", 5)
+ monkeypatch.setattr(gui_jobs, "CACHE_ENTRIES", 2)
+ def preview(path, work, snapshot, progress):
+ (work / "original.wav").write_bytes(b"preview")
+ (work / "processed.wav").write_bytes(b"preview")
+ return {}
+ monkeypatch.setattr(gui_jobs, "render_preview", preview)
+ jobs = gui_jobs.Jobs(tmp_path, lambda value: rooted(tmp_path, value))
+ snapshot = {"path": source.name, "input_id": gui_jobs.identity(source)}
+ try:
+ results = []
+ for index in range(10):
+ job = jobs.submit("preview", snapshot | {"start": index})
+ result = terminal(jobs, job["id"])
+ assert result["status"] == "succeeded", result
+ results.append(result["result"]["result_id"])
+ assert len(jobs.list()) == 5
+ assert len(jobs.cache) == 2
+ assert len(list(jobs.work.iterdir())) == 2
+ with pytest.raises(KeyError):
+ jobs.media(results[0], "original")
+ assert jobs.media(results[-1], "original") == b"preview"
+ with pytest.raises(KeyError):
+ jobs.media(results[-1], "../original")
+ # Byte budget independently evicts even below the entry-count limit.
+ monkeypatch.setattr(gui_jobs, "CACHE_BYTES", 1)
+ with jobs.condition:
+ jobs._evict()
+ assert not jobs.cache
+ assert not list(jobs.work.iterdir())
+ finally:
+ jobs.close()
+
+
+def test_queued_input_replacement_is_rejected_and_shutdown_cancels_scan(wav, tmp_path, monkeypatch):
+ source = wav()
+ started = threading.Event()
+ def scan(path, progress):
+ started.set()
+ while True:
+ progress("Scanning", 1, 10)
+ time.sleep(0.01)
+ monkeypatch.setattr(gui_jobs, "waveform", scan)
+ jobs = gui_jobs.Jobs(tmp_path, lambda value: rooted(tmp_path, value))
+ snapshot = {"path": source.name, "input_id": gui_jobs.identity(source)}
+ running = jobs.submit("scan", snapshot)
+ assert started.wait(5)
+ queued = jobs.submit("scan", snapshot)
+ with source.open("ab") as handle:
+ handle.write(b"replacement")
+ jobs.cancel(running["id"])
+ assert terminal(jobs, queued["id"])["status"] == "failed"
+ assert "changed" in jobs.get(queued["id"])["error"]
+ # Submit with updated identity and exercise app shutdown cleanup.
+ last = jobs.submit("scan", snapshot | {"input_id": gui_jobs.identity(source)})
+ jobs.close()
+ assert jobs.get(last["id"])["status"] == "cancelled"
+ assert not jobs.work.exists()
diff --git a/lib/project/tests/test_gui_launcher.py b/lib/project/tests/test_gui_launcher.py
new file mode 100644
index 0000000..8111c67
--- /dev/null
+++ b/lib/project/tests/test_gui_launcher.py
@@ -0,0 +1,113 @@
+"""Exercise bootstrap/config routing without downloading or changing the real runtime."""
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+
+import pytest
+
+from producer import cli
+
+LAUNCHER = Path(__file__).resolve().parents[3] / "producer.sh"
+
+
+def test_cli_gui_dispatch_preserves_configuration_flags(tmp_path, monkeypatch):
+ from producer import gui
+ config = tmp_path / "config.toml"
+ config.write_text('profile = "narrator"\n')
+ received = []
+ monkeypatch.setattr(gui, "serve", lambda *args, **kwargs: received.append((args, kwargs)))
+ monkeypatch.setattr(sys, "argv", ["producer", "gui", "--root", str(tmp_path), "--port", "9001",
+ "--config", str(config), "--profile", "radio", "--target-lufs", "-21", "--set", "target_lufs=-18"])
+ cli.main()
+ assert received == [((tmp_path, 9001), {"config": config, "profile": "radio", "overrides": {"target_lufs": -18}})]
+
+
+def test_launcher_only_gui_installs_extra_and_preserves_config_injection(tmp_path):
+ checkout = tmp_path / "checkout"
+ checkout.mkdir()
+ launcher = checkout / "producer.sh"
+ launcher.write_text(LAUNCHER.read_text())
+ launcher.chmod(0o755)
+ (checkout / "config.toml").write_text('profile = "narrator"\n')
+ (checkout / "lib/project").mkdir(parents=True)
+ tools = tmp_path / "tools"
+ tools.mkdir()
+ log, forwarded = tmp_path / "install.jsonl", tmp_path / "forwarded.json"
+ fake_python = (f"#!{sys.executable}\nimport json,sys\nfrom pathlib import Path\n"
+ f"if '-m' in sys.argv: Path({str(forwarded)!r}).write_text(json.dumps(sys.argv[1:]))\n")
+ uv = tools / "uv"
+ uv.write_text(f"#!{sys.executable}\n"
+ "import json,sys\nfrom pathlib import Path\n"
+ f"with Path({str(log)!r}).open('a') as out: out.write(json.dumps(sys.argv[1:])+'\\n')\n"
+ "if sys.argv[1] == 'venv':\n"
+ " python = Path(sys.argv[-1]) / 'bin/python'\n"
+ " python.parent.mkdir(parents=True, exist_ok=True)\n"
+ f" python.write_text({fake_python!r})\n"
+ " python.chmod(0o755)\n")
+ uv.chmod(0o755)
+ env = os.environ.copy() | {"PATH": f"{tools}:{os.environ['PATH']}"}
+ def launch(*args):
+ result = subprocess.run([str(launcher), *args], cwd=checkout, env=env,
+ capture_output=True, text=True, timeout=15)
+ assert result.returncode == 0, result.stderr
+ return json.loads(forwarded.read_text())
+ args = launch("config", "--json")
+ assert args == ["-I", "-m", "producer", "config", f"--config={checkout / 'config.toml'}", "--json"]
+ calls = [json.loads(line) for line in log.read_text().splitlines()]
+ installs = [call for call in calls if call[0] == "pip"]
+ assert len(installs) == 1 and installs[0][-1].endswith("[bundled-ffmpeg]")
+ args = launch("gui", "--root", str(checkout), "--port", "8766")
+ assert args[3:5] == ["gui", f"--config={checkout / 'config.toml'}"]
+ calls = [json.loads(line) for line in log.read_text().splitlines()]
+ installs = [call for call in calls if call[0] == "pip"]
+ assert len(installs) == 2 and installs[1][-1].endswith("[bundled-ffmpeg,gui]")
+ before = log.read_text()
+ args = launch("gui", "--config=custom.toml")
+ assert args[-2:] == ["gui", "--config=custom.toml"]
+ launch("config")
+ assert log.read_text() == before # GUI/core marker reuse, no extra installs.
+
+
+def test_cli_commands_import_without_optional_web_dependencies():
+ # A fresh interpreter explicitly denies optional imports, even if installed.
+ code = """
+import sys
+class Deny:
+ def find_spec(self, fullname, *args):
+ if fullname.split('.')[0] in ('fastapi', 'uvicorn', 'starlette'):
+ raise AssertionError('CLI imported optional GUI dependency: ' + fullname)
+sys.meta_path.insert(0, Deny())
+from producer.cli import parser
+assert parser().parse_args(['config']).command == 'config'
+"""
+ result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
+ assert result.returncode == 0, result.stderr
+
+
+def test_server_binds_only_loopback_and_prints_fragment_token(tmp_path, monkeypatch, capsys):
+ pytest.importorskip("fastapi")
+ uvicorn = pytest.importorskip("uvicorn")
+ import socket
+ from producer import gui
+ with socket.socket() as reservation:
+ reservation.bind(("127.0.0.1", 0))
+ port = reservation.getsockname()[1]
+ seen = []
+ class Server:
+ def __init__(self, config):
+ assert config.host == "127.0.0.1"
+ assert config.proxy_headers is False
+ assert config.access_log is False
+ def run(self, sockets):
+ seen.append(sockets[0].getsockname())
+ monkeypatch.setattr(uvicorn, "Server", Server)
+ gui.serve(tmp_path, port)
+ assert seen == [("127.0.0.1", port)]
+ output = capsys.readouterr().out
+ assert f"http://127.0.0.1:{port}/#token=" in output
+ assert len(output.split("#token=")[1].splitlines()[0]) >= 43
+ for invalid in (0, -1, 65536):
+ with pytest.raises(ValueError):
+ gui.serve(tmp_path, invalid)
diff --git a/lib/project/tests/test_pauses.py b/lib/project/tests/test_pauses.py
new file mode 100644
index 0000000..b67f98d
--- /dev/null
+++ b/lib/project/tests/test_pauses.py
@@ -0,0 +1,200 @@
+import hashlib
+import io
+import sys
+from types import SimpleNamespace
+
+import numpy as np
+import pytest
+import soundfile as sf
+
+from producer import pauses, pipeline
+from producer.config import resolve
+
+
+def test_hysteresis_keeps_short_words_and_closes_at_eof():
+ values = [0, 0.5, 0.25, 0.1, 0, 0.8]
+ regions = pauses.speech_regions(values, 6 * 1536 - 7, 48000, 0.35)
+ assert regions == [(1536, 4608), (7680, 6 * 1536 - 7)]
+
+
+@pytest.mark.parametrize("values", [[0.5], [0.5, float("nan")], [0.5, 1.1], [0.5, "bad"]])
+def test_invalid_detector_output_is_not_silently_accepted(values):
+ with pytest.raises(RuntimeError):
+ pauses.speech_regions(values, 3072, 48000, 0.35)
+
+
+def test_padding_merges_close_words_but_keeps_long_gaps():
+ settings = resolve()
+ # One millisecond per sample makes these expected boundaries easy to read.
+ speech = [(500, 800), (1000, 1300), (2000, 2300)]
+ assert pauses.pause_regions(speech, 3000, 1000, settings) == [
+ (0, 380), (1420, 1880), (2420, 3000)]
+ # No room for padding plus fade plus the requested zero core: leave alone.
+ assert pauses.pause_regions([(0, 100), (400, 1000)], 1000, 1000, settings) == []
+
+
+def test_streamed_envelope_preserves_onsets_and_duration(wav, tmp_path, progress):
+ sr = 48000
+ audio = np.full(3 * sr + 7, 0.001, dtype="float32")
+ source = wav(audio)
+ target = tmp_path / "silenced.wav"
+ # Deliberately straddle the 65536-sample processing boundary.
+ a, b, fade = 60000, 100000, 480
+ pauses.silence_pauses(source, target, [(a, b)], 10, progress)
+ result, rate = sf.read(target, dtype="float32")
+ assert rate == sr and result.shape == audio.shape
+ np.testing.assert_array_equal(result[:a + 1], audio[:a + 1])
+ np.testing.assert_array_equal(result[b - 1:], audio[b - 1:])
+ assert np.count_nonzero(result[a + fade:b - fade]) == 0
+ assert np.all(np.diff(result[a:a + fade]) <= 0)
+ assert np.all(np.diff(result[b - fade:b]) >= 0)
+ assert np.max(np.abs(np.diff(result))) < 0.00001
+
+
+def test_file_edges_do_not_fade_noise_back_in(wav, tmp_path, progress):
+ source = wav(np.full(48000, 0.001))
+ target = tmp_path / "edges.wav"
+ pauses.silence_pauses(source, target, [(0, 12000), (36000, 48000)], 10, progress)
+ data = sf.read(target)[0]
+ assert np.count_nonzero(data[:11520]) == 0
+ assert np.count_nonzero(data[36480:]) == 0
+ np.testing.assert_array_equal(data[12000:36000], sf.read(source)[0][12000:36000])
+
+
+def test_no_intervals_is_exact_bypass(wav, tmp_path, progress):
+ source = wav(np.random.default_rng(0).normal(0, 0.01, 200001))
+ target = tmp_path / "out.wav"
+ pauses.silence_pauses(source, target, [], 10, progress)
+ np.testing.assert_array_equal(sf.read(source)[0], sf.read(target)[0])
+ with pytest.raises(ValueError, match="distinct"):
+ pauses.silence_pauses(source, source, [], 10, progress)
+
+
+@pytest.mark.parametrize("rate", [44100, 48000])
+def test_pipeline_detects_before_denoise_and_exports_zero_cores(
+ wav, tmp_path, progress, ffmpeg, monkeypatch, rate):
+ sr = 48000
+ t = np.arange(2 * sr) / sr
+ speech = 0.15 * np.sin(2 * np.pi * 220 * t)
+ audio = np.concatenate([speech, np.full(sr, 0.001), speech])
+ source = wav(audio)
+ original = source.read_bytes()
+ events = []
+
+ def detect(prepared, *args):
+ events.append("detect")
+ data = sf.read(prepared)[0]
+ assert np.max(np.abs(data)) > 0.14 # Not the attenuated denoiser output.
+ return [(2 * sr + 5760, 3 * sr - 5760)], {"speech_regions": 2, "pauses_silenced": 1}
+
+ def denoise(prepared, target, *args, **kwargs):
+ assert events == ["detect"]
+ events.append("denoise")
+ data, sr = sf.read(prepared)
+ sf.write(target, data * 0.8, sr, subtype="FLOAT")
+
+ monkeypatch.setattr(pauses, "detect_pauses", detect)
+ monkeypatch.setattr("producer.ai.denoise", denoise)
+ settings = resolve(overrides={"pause_silence": True, "sample_rate": rate})
+ target = tmp_path / "master.wav"
+ report = pipeline.process(source, target, settings, progress)
+ data, sr = sf.read(target)
+ assert sr == rate and len(data) == 5 * rate
+ # Resampling and dynamics may have tails at edges; the interior is zero,
+ # even after full normalization and final 32-bit encoding.
+ assert np.count_nonzero(data[int(2.3 * rate):int(2.7 * rate)]) == 0
+ assert report["pause_cleanup"]["pauses_silenced"] == 1
+ assert report["silence_removed_seconds"] == 0
+ assert abs(report["master_loudness"]["input_i"] - settings.target_lufs) <= 0.5
+ assert report["master_loudness"]["input_tp"] <= settings.true_peak_db + 0.1
+ assert source.read_bytes() == original
+
+
+def test_no_speech_fails_open_and_skips_requested_trim(
+ wav, tmp_path, bypass, progress, ffmpeg, monkeypatch):
+ bypass.pause_silence = bypass.trim_silence = True
+ monkeypatch.setattr(pauses, "detect_pauses", lambda *args: ([], {"speech_regions": 0}))
+ source = wav()
+ report = pipeline.process(source, tmp_path / "out.wav", bypass, progress)
+ assert report["master"]["frames"] == report["input"]["frames"]
+ assert any("found no speech" in warning for warning in report["warnings"])
+ assert report["gaps_trimmed"] == 0
+
+
+def test_detector_failure_does_not_publish_or_fallback(
+ wav, tmp_path, bypass, progress, ffmpeg, monkeypatch):
+ bypass.pause_silence = True
+ def fail(*args):
+ raise RuntimeError("injected detector failure")
+ monkeypatch.setattr(pauses, "detect_pauses", fail)
+ target = tmp_path / "master.wav"
+ target.write_bytes(b"previous")
+ with pytest.raises(RuntimeError, match="injected detector"):
+ pipeline.process(wav(), target, bypass, progress, overwrite=True)
+ assert target.read_bytes() == b"previous"
+ assert not list(tmp_path.glob(".producer-*"))
+
+
+def test_vad_trimming_only_removes_zero_cores(wav, tmp_path, bypass, progress, ffmpeg, monkeypatch):
+ sr = 48000
+ bypass.pause_silence = bypass.trim_silence = True
+ bypass.gap_threshold_db = -20 # Would eat this entire quiet recording in legacy mode.
+ speech = np.full(sr, 0.005)
+ source = wav(np.concatenate([speech, np.full(3 * sr, 0.001), speech]))
+ monkeypatch.setattr(pauses, "detect_pauses", lambda *args:
+ ([(sr + 5760, 4 * sr - 5760)], {"speech_regions": 2}))
+ target = tmp_path / "out.wav"
+ report = pipeline.process(source, target, bypass, progress)
+ data = sf.read(target)[0]
+ np.testing.assert_allclose(data[:sr], speech, atol=1e-8)
+ np.testing.assert_allclose(data[-sr:], speech, atol=1e-8)
+ assert report["gaps_trimmed"] == 1
+ assert report["silence_removed_seconds"] > 1
+
+
+def test_worker_uses_state_context_and_pads_partial_last_frame(wav, tmp_path, monkeypatch):
+ from producer.vad_worker import probabilities
+ calls = []
+ class Session:
+ def __init__(self, *args, **kwargs):
+ assert kwargs["providers"] == ["CPUExecutionProvider"]
+ def run(self, _, inputs):
+ calls.append(inputs)
+ return np.array([[0.75]]), inputs["state"] + 1
+ monkeypatch.setitem(sys.modules, "onnxruntime", SimpleNamespace(
+ SessionOptions=SimpleNamespace, InferenceSession=Session))
+ audio = np.arange(600, dtype="float32") / 600
+ path = tmp_path / "vad.wav"
+ sf.write(path, audio, 16000, subtype="FLOAT")
+ assert probabilities(path, tmp_path / "model") == [0.75, 0.75]
+ assert calls[0]["input"].shape == (1, 576)
+ assert np.count_nonzero(calls[0]["input"][0, :64]) == 0
+ np.testing.assert_array_equal(calls[1]["input"][0, :64], audio[448:512])
+ assert np.count_nonzero(calls[1]["input"][0, 152:]) == 0
+ assert np.all(calls[1]["state"] == 1)
+
+
+def test_model_download_is_verified_and_cached(tmp_path, monkeypatch, progress):
+ root = tmp_path / "runtime"
+ monkeypatch.setattr(pauses, "data_dir", lambda: root)
+ environment = root / "vad-onnx-1.20.1-py311-v1"
+ (environment / "bin").mkdir(parents=True)
+ (environment / "bin/python").touch()
+ (environment / ".producer-ready").touch()
+ payload = b"test model"
+ monkeypatch.setattr(pauses, "MODEL_SHA256", hashlib.sha256(payload).hexdigest())
+ downloads = []
+ def download(*args, **kwargs):
+ downloads.append(args)
+ return io.BytesIO(payload)
+ monkeypatch.setattr(pauses.urllib.request, "urlopen", download)
+ python, model = pauses.ensure_vad(progress)
+ assert model.read_bytes() == payload
+ assert pauses.ensure_vad(progress) == (python, model)
+ assert len(downloads) == 1
+ model.write_bytes(b"corrupt cached model")
+ monkeypatch.setattr(pauses.urllib.request, "urlopen", lambda *a, **k: io.BytesIO(b"bad"))
+ with pytest.raises(RuntimeError, match="checksum mismatch"):
+ pauses.ensure_vad(progress)
+ assert model.read_bytes() == b"corrupt cached model"
+ assert not list(model.parent.glob(".silero-*"))