diff options
Diffstat (limited to 'lib/project/tests')
| -rw-r--r-- | lib/project/tests/gui_state.test.mjs | 10 | ||||
| -rw-r--r-- | lib/project/tests/test_gui.py | 162 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_assets.py | 14 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_browser.py | 76 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_browser_races.py | 27 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_jobs.py | 20 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_launcher.py | 16 | ||||
| -rw-r--r-- | lib/project/tests/test_pipeline.py | 49 |
8 files changed, 214 insertions, 160 deletions
diff --git a/lib/project/tests/gui_state.test.mjs b/lib/project/tests/gui_state.test.mjs index 10775eb..0556a4a 100644 --- a/lib/project/tests/gui_state.test.mjs +++ b/lib/project/tests/gui_state.test.mjs @@ -1,6 +1,6 @@ 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'; +import {parseTime, formatTime, selection, stable, matches, monitoringGain} 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); @@ -12,11 +12,11 @@ test('exact excerpt times work beyond four hours without wrapping', () => { 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'}}; +test('preview identity includes uploaded source, exact selection and every requested setting', () => { + const snapshot = {upload_id: 'upload', 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); + for (const change of [{upload_id: 'other'}, {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})); }); @@ -26,6 +26,4 @@ test('monitoring is attenuation only and independent of export settings', () => 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 index c9e5285..e490e99 100644 --- a/lib/project/tests/test_gui.py +++ b/lib/project/tests/test_gui.py @@ -1,4 +1,4 @@ -"""Optional GUI API, origin/root boundary, snapshots, and real sound previews.""" +"""Optional GUI API, origin boundary, uploads, snapshots, and real sound previews.""" from dataclasses import asdict import hashlib from pathlib import Path @@ -16,17 +16,28 @@ 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: + with TestClient(create_app(8765, output_dir=tmp_path), base_url=URL) as client: yield client +_UPLOADED = {} + + +def upload(client, path, name=None): + """Upload once per (file, name) and reuse the entry, like the browser UI.""" + key = (id(path), name) + if key not in _UPLOADED: + response = client.post("/api/uploads", params={"name": name or path.name}, content=path.read_bytes()) + assert response.status_code == 200, response.text + _UPLOADED[key] = response.json() + return _UPLOADED[key] + + def wait(client, job_id): deadline = time.monotonic() + 30 while time.monotonic() < deadline: @@ -38,68 +49,79 @@ def wait(client, job_id): 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}) + entry = upload(client, path) + response = client.post("/api/jobs", json={"kind": kind, "upload_id": entry["upload_id"], + "input_id": entry["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: +def test_security_origins_hosts_and_headers(tmp_path): + with TestClient(create_app(8765), 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").status_code == 200 + assert "token" not in client.get("/").text.lower() + for headers in [{"Host": "evil.example:8765"}, + {"Host": "localhost:8765"}, + {"Origin": "https://evil.example"}, + {"Origin": "null"}, + {"Origin": "http://127.0.0.1:9999"}, + {"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}) + response = client.get("/api/schema", headers={"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 + assert client.post("/api/resolve", content=b"x" * 65537).status_code == 413 + assert client.post("/api/resolve", content="{").status_code == 400 -def test_root_browser_symlinks_traversal_and_no_arbitrary_download(client, tmp_path, wav): +def test_upload_pipeline_validates_names_and_content(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 + entry = upload(client, source) + assert entry["name"] == "input.wav" + assert entry["duration"] == pytest.approx(3) + info = client.get(f"/api/uploads/{entry['upload_id']}").json() + assert info["input_id"] == entry["input_id"] + # Display names are sanitized; they never reach the filesystem. + escaped = upload(client, source, name="../evil name<>.wav") + assert ".." not in escaped["name"] and "/" not in escaped["name"] + assert client.get("/api/uploads/unknown-id").status_code == 400 + (tmp_path / "note.txt").write_text("not a wav") + assert client.post("/api/uploads", params={"name": "note.txt"}, content=b"nope").status_code == 400 + assert client.post("/api/uploads", params={"name": "x.wav"}, content=b"not a wav").status_code == 400 + stereo = wav(np.zeros((20, 2)), name="stereo.wav") + response = client.post("/api/uploads", params={"name": "stereo.wav"}, content=stereo.read_bytes()) + assert response.status_code == 200 and response.json()["channels"] == 2 + assert "downmixed" in response.json()["notice"] + assert "downmixed" in client.get(f"/api/uploads/{response.json()['upload_id']}").text + surround = wav(np.zeros((20, 3)), name="surround.wav") + assert client.post("/api/uploads", params={"name": "surround.wav"}, content=surround.read_bytes()).status_code == 400 + # Jobs require a known upload and a matching identity. + assert client.post("/api/jobs", json={"kind": "scan", "upload_id": "unknown", + "input_id": "x"}).status_code == 400 + assert client.post("/api/jobs", json={"kind": "scan", "upload_id": entry["upload_id"], + "input_id": "wrong"}).status_code == 400 + assert "changed" in client.post("/api/jobs", json={"kind": "scan", "upload_id": entry["upload_id"], + "input_id": "wrong"}).text 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"]) + help_for = {item["name"]: item["help"] for item in schema["fields"]} + assert len(set(help_for.values())) == len(help_for) + assert "cannot be 0" in help_for["fft_reduction_db"] + assert "Uncheck" in help_for["highpass"] + assert "Uncheck" in help_for["pause_silence"] and "Uncheck" in help_for["trim_silence"] + assert "0 to disable" in help_for["hum_hz"] assert schema["settings"] == asdict(Settings()) + assert Path(schema["output_dir"]).is_absolute() 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 @@ -115,9 +137,9 @@ def test_schema_complete_strict_resolution_and_preset_roundtrip(client, bypass): 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}) + app = create_app(8765, 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: + with TestClient(app, base_url=URL) as client: settings = client.get("/api/schema").json()["settings"] assert settings["profile"] == "radio" and settings["compressor_ratio"] == 3.5 assert settings["target_lufs"] == -22 @@ -138,15 +160,9 @@ def test_scan_fixed_bins_cache_and_stale_identity(client, wav, monkeypatch): 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): +def test_real_preview_context_exact_crop_bypass_and_export_trim(client, tmp_path, wav, bypass, ffmpeg): t = np.arange(48000 * 8) / 48000 samples = 0.15 * np.sin(2 * np.pi * 440 * t) source = wav(samples) @@ -183,35 +199,41 @@ def test_real_preview_context_exact_crop_bypass_and_export_trim(client, wav, byp 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"]) + exported = wait(client, submit(client, source, "export", settings=requested, + output_dir=str(tmp_path))["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 Path(path).is_absolute() and Path(path).is_file() + assert len(list(tmp_path.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): +def test_export_output_directory_rules(client, wav, bypass, tmp_path): + source = wav() + entry = upload(client, source) + requested = asdict(bypass) + base = {"kind": "export", "upload_id": entry["upload_id"], "input_id": entry["input_id"], + "settings": requested} + assert client.post("/api/jobs", json={**base, "output_dir": "relative/folder"}).status_code == 400 + assert client.post("/api/jobs", json={**base, "output_dir": str(tmp_path / "missing")}).status_code == 400 + missing = client.post("/api/jobs", json={**base}) + assert missing.status_code == 202 + job = wait(client, missing.json()["id"]) + assert job["status"] == "succeeded", job + for path in job["result"]["paths"]: + assert Path(path).is_absolute() and Path(path).is_file() + Path(path).relative_to(tmp_path) + +def test_preview_bounds_and_empty_inputs(client, wav): source = wav() + entry = upload(client, source) 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}) + response = client.post("/api/jobs", json={"kind": "preview", "upload_id": entry["upload_id"], + "input_id": entry["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): diff --git a/lib/project/tests/test_gui_assets.py b/lib/project/tests/test_gui_assets.py index b7f949a..961f15e 100644 --- a/lib/project/tests/test_gui_assets.py +++ b/lib/project/tests/test_gui_assets.py @@ -25,8 +25,8 @@ class AssetReferences(HTMLParser): 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: +def test_gui_packaged_external_assets_and_condensed_workflow(tmp_path): + with TestClient(create_app(8765, output_dir=tmp_path), base_url="http://127.0.0.1:8765") as client: page = client.get("/") assert page.status_code == 200 parser = AssetReferences() @@ -35,12 +35,14 @@ def test_gui_packaged_external_assets_and_guided_workflow(tmp_path): 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 + assert "approximate, not an exact export preview" in page.text + for name in ("Recording", "Settings", "Compare", "Export", "Activity"): + assert f">{name}</h2>" in page.text or name in page.text + for banned in ("#token", "private by design", "Make it sound like you"): + assert banned not 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 + assert client.get("/api/schema").status_code == 200 diff --git a/lib/project/tests/test_gui_browser.py b/lib/project/tests/test_gui_browser.py index 1e38964..385de44 100644 --- a/lib/project/tests/test_gui_browser.py +++ b/lib/project/tests/test_gui_browser.py @@ -1,4 +1,4 @@ -"""Opt-in real Chromium/loopback smoke; no AI models or recording uploads. +"""Opt-in real Chromium/loopback smoke; no AI models. Uploads come from the browser. Install Playwright externally, then run: PRODUCER_BROWSER_TESTS=1 python -m pytest tests/test_gui_browser.py -q @@ -8,8 +8,6 @@ The long overview case uses mocked fixed-size scan metadata, not 4-hour AI infer from dataclasses import asdict import hashlib import json -import os -import re from pathlib import Path import socket import threading @@ -31,7 +29,6 @@ def studio(tmp_path, wav, bypass, ffmpeg, monkeypatch): 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 @@ -43,12 +40,11 @@ def studio(tmp_path, wav, bypass, ffmpeg, monkeypatch): 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)) + app = create_app(port, output_dir=tmp_path, 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() @@ -57,7 +53,7 @@ def studio(tmp_path, wav, bypass, ffmpeg, monkeypatch): 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} + yield {"url": f"http://127.0.0.1:{port}", "root": tmp_path, "source": source, "control": control} finally: control["release"].set() server.should_exit = True @@ -80,19 +76,21 @@ def test_gui_browser_real_workflow(studio): 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"]) + page.goto(studio["url"]) 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 "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() + # Dependent controls of a disabled effect are visibly inactive. + expect(page.locator("#setting-denoise_strength")).to_be_disabled() + expect(page.locator("#setting-fft_reduction_db")).to_be_disabled() + expect(page.locator("#setting-highpass_hz")).to_be_disabled() + expect(page.locator("#setting-highpass_hz")).to_have_value("70") # Unsupported input errors are human-readable, not raw HTML. - page.get_by_role("button", name=re.compile(r"WAV\s*Stereo.wav")).click() + page.locator("#pick-files").set_input_files([studio["root"] / "Stereo.wav", studio["source"]]) 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. @@ -110,6 +108,7 @@ def test_gui_browser_real_workflow(studio): # Explicit render, requested pacing+MP3 preserved in snapshot but bypassed internally. page.locator("#setting-trim_silence").check() page.locator("#setting-mp3").check() + expect(page.locator("#setting-mp3_bitrate")).not_to_be_disabled() page.locator("#render-preview").click() expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000) assert len(media_requests) == 2 @@ -140,39 +139,39 @@ def test_gui_browser_real_workflow(studio): 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") + # Editing a control invalidates the comparison; importing the preset restores it. + page.locator("#setting-eq").check() 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") + expect(page.locator("#setting-eq")).not_to_be_checked() # 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) + page.locator("#setting-eq").check() + saved = page.evaluate("JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k => k.startsWith('producer-gui-v1'))))") + assert saved["comparisons"][0]["snapshot"]["settings"]["eq"] is False + assert saved["presets"][0]["settings"]["eq"] is False # 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") + page.locator("#setting-eq").uncheck() 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("#setting-eq")).not_to_be_checked() 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() + expect(page.locator("#setting-target_lufs")).not_to_be_disabled() 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") + page.locator("#output-dir").fill(str(studio["root"] / "delivery")) preview_requests = len(media_requests) page.locator("#prepare-export").click() expect(page.locator("#export-dialog")).to_be_visible() @@ -195,7 +194,7 @@ def test_gui_browser_real_workflow(studio): 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. + # Reload keeps 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>") @@ -206,18 +205,8 @@ def test_gui_browser_real_workflow(studio): 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. + # Expected HTTP errors for stereo and the invalid constraint are allowed. assert not [msg for msg in console_errors if "Content Security Policy" in msg or "Refused" in msg] browser.close() @@ -231,19 +220,20 @@ def test_gui_browser_long_overview(studio): 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", "") + page.goto(studio["url"]) # 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"} + info = {"upload_id": "long-upload", "name": "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"}, + job = {"id": "mock-scan", "kind": "scan", + "snapshot": {"upload_id": "long-upload", "name": "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/uploads*", 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() + page.locator("#pick-files").set_input_files(studio["source"]) 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") diff --git a/lib/project/tests/test_gui_browser_races.py b/lib/project/tests/test_gui_browser_races.py index fab60ba..5f2638b 100644 --- a/lib/project/tests/test_gui_browser_races.py +++ b/lib/project/tests/test_gui_browser_races.py @@ -15,8 +15,8 @@ def test_gui_browser_cancellation_is_not_pretend(studio): 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() + page.goto(studio["url"]) + page.locator("#pick-files").set_input_files(studio["source"]) expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) studio["control"]["hold"] = True page.locator("#render-preview").click() @@ -48,8 +48,8 @@ def test_gui_browser_late_audio_is_aborted_and_urls_are_released(studio): 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() + page.goto(studio["url"]) + page.locator("#pick-files").set_input_files(studio["source"]) expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) page.locator("#selection-start").fill("1") page.locator("#selection-end").fill("4") @@ -99,10 +99,9 @@ def test_gui_browser_nonzero_monitoring_gain_is_playback_only(studio): 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() + page.goto(studio["url"]) + page.locator("#pick-files").set_input_files(studio["source"]) 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") @@ -137,9 +136,9 @@ def test_delayed_job_list_preserves_new_submission_intent(studio, kind): 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.goto(studio["url"]) if kind == "preview": - page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click() + page.locator("#pick-files").set_input_files(studio["source"]) expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000) held = [] @@ -158,7 +157,7 @@ def test_delayed_job_list_preserves_new_submission_intent(studio, kind): 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() + page.locator("#pick-files").set_input_files(studio["source"]) else: page.locator("#render-preview").click() assert posted.value.status == 202 @@ -186,8 +185,8 @@ def test_delayed_audio_resume_does_not_start_superseded_playback(studio, interru 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() + page.goto(studio["url"]) + page.locator("#pick-files").set_input_files(studio["source"]) 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) @@ -200,8 +199,8 @@ def test_delayed_audio_resume_does_not_start_superseded_playback(studio, interru 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) + status=500, content_type="application/json", body='{"detail":"down"}')) + expect(page.locator("#connection")).to_contain_text("interrupted", timeout=10000) page.evaluate("releaseAudioResume()") # Let both the resume and any erroneously scheduled play promise settle. page.wait_for_timeout(300) diff --git a/lib/project/tests/test_gui_jobs.py b/lib/project/tests/test_gui_jobs.py index 6116a72..d44772f 100644 --- a/lib/project/tests/test_gui_jobs.py +++ b/lib/project/tests/test_gui_jobs.py @@ -7,8 +7,6 @@ import time import pytest from producer import gui_jobs, setup -from producer.gui import rooted - def terminal(jobs, job_id): deadline = time.monotonic() + 10 @@ -34,13 +32,13 @@ def test_single_worker_bounded_queue_and_running_process_cancellation(wav, tmp_p "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)} + jobs = gui_jobs.Jobs() + snapshot = {"upload_id": jobs.register_upload(source, 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" + first["snapshot"]["name"] = "renamed.wav" queued = [jobs.submit("scan", snapshot) for _ in range(3)] with pytest.raises(gui_jobs.Busy): jobs.submit("scan", snapshot) @@ -71,8 +69,8 @@ def test_history_and_result_cache_are_bounded_and_evicted_media_expires(wav, tmp (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)} + jobs = gui_jobs.Jobs() + snapshot = {"upload_id": jobs.register_upload(source, source.name), "input_id": gui_jobs.identity(source)} try: results = [] for index in range(10): @@ -82,7 +80,7 @@ def test_history_and_result_cache_are_bounded_and_evicted_media_expires(wav, tmp results.append(result["result"]["result_id"]) assert len(jobs.list()) == 5 assert len(jobs.cache) == 2 - assert len(list(jobs.work.iterdir())) == 2 + assert len([item for item in jobs.work.iterdir() if item.name != "uploads"]) == 2 with pytest.raises(KeyError): jobs.media(results[0], "original") assert jobs.media(results[-1], "original") == b"preview" @@ -93,7 +91,7 @@ def test_history_and_result_cache_are_bounded_and_evicted_media_expires(wav, tmp with jobs.condition: jobs._evict() assert not jobs.cache - assert not list(jobs.work.iterdir()) + assert not [item for item in jobs.work.iterdir() if item.name != "uploads"] finally: jobs.close() @@ -107,8 +105,8 @@ def test_queued_input_replacement_is_rejected_and_shutdown_cancels_scan(wav, tmp 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)} + jobs = gui_jobs.Jobs() + snapshot = {"upload_id": jobs.register_upload(source, source.name), "input_id": gui_jobs.identity(source)} running = jobs.submit("scan", snapshot) assert started.wait(5) queued = jobs.submit("scan", snapshot) diff --git a/lib/project/tests/test_gui_launcher.py b/lib/project/tests/test_gui_launcher.py index 8111c67..758d034 100644 --- a/lib/project/tests/test_gui_launcher.py +++ b/lib/project/tests/test_gui_launcher.py @@ -18,10 +18,10 @@ def test_cli_gui_dispatch_preserves_configuration_flags(tmp_path, monkeypatch): 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", + monkeypatch.setattr(sys, "argv", ["producer", "gui", "--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}})] + assert received == [((9001,), {"config": config, "profile": "radio", "overrides": {"target_lufs": -18}})] def test_launcher_only_gui_installs_extra_and_preserves_config_injection(tmp_path): @@ -58,7 +58,7 @@ def test_launcher_only_gui_installs_extra_and_preserves_config_injection(tmp_pat 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") + args = launch("gui", "--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"] @@ -86,7 +86,7 @@ assert parser().parse_args(['config']).command == 'config' assert result.returncode == 0, result.stderr -def test_server_binds_only_loopback_and_prints_fragment_token(tmp_path, monkeypatch, capsys): +def test_server_binds_only_loopback_and_prints_plain_url(tmp_path, monkeypatch, capsys): pytest.importorskip("fastapi") uvicorn = pytest.importorskip("uvicorn") import socket @@ -103,11 +103,11 @@ def test_server_binds_only_loopback_and_prints_fragment_token(tmp_path, monkeypa def run(self, sockets): seen.append(sockets[0].getsockname()) monkeypatch.setattr(uvicorn, "Server", Server) - gui.serve(tmp_path, port) + gui.serve(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 + assert f"http://127.0.0.1:{port}/\n" in output + assert "#token" not in output for invalid in (0, -1, 65536): with pytest.raises(ValueError): - gui.serve(tmp_path, invalid) + gui.serve(invalid) diff --git a/lib/project/tests/test_pipeline.py b/lib/project/tests/test_pipeline.py index fe04519..0bc2cef 100644 --- a/lib/project/tests/test_pipeline.py +++ b/lib/project/tests/test_pipeline.py @@ -64,13 +64,13 @@ def test_existing_report_log_is_replaced_without_overwrite(wav, tmp_path, bypass @pytest.mark.parametrize("kind,message", [ ("empty", "empty"), ("silence", "silent or too quiet"), ("quiet", "silent or too quiet"), ("surround", "Only mono or stereo"), - ("nonfinite", "non-finite"), ("stereo", "downmix to mono"), + ("nonfinite", "non-finite"), ]) def test_unusable_inputs_leave_no_outputs(wav, tmp_path, bypass, progress, ffmpeg, kind, message): samples = { "empty": np.empty(0), "silence": np.zeros(48000), "quiet": np.full(48000, 1e-6), "surround": np.ones((4800, 3)) * 0.1, - "nonfinite": np.array([0.1, np.nan]), "stereo": np.ones((4800, 2)) * 0.1, + "nonfinite": np.array([0.1, np.nan]), }[kind] source = wav(samples) target = tmp_path / "outputs" / "master.wav" @@ -79,6 +79,51 @@ def test_unusable_inputs_leave_no_outputs(wav, tmp_path, bypass, progress, ffmpe assert not target.parent.exists() +def test_downmix_to_mono_strategies(wav, tmp_path, progress): + from producer.audio import downmix_to_mono + time = np.arange(48000 * 3) / 48000 + voice = 0.1 * np.sin(2 * np.pi * 440 * time) + # Identical channels: a plain average is lossless and keeps full level. + mono = tmp_path / "same.wav" + method = downmix_to_mono(wav(np.column_stack([voice, voice])), mono, progress) + assert method == "the channels were averaged" + assert sf.info(mono).channels == 1 + assert np.abs(sf.read(mono)[0] - voice).max() == pytest.approx(0, abs=1e-7) + # Inverted channels: the phase-cancel case the old error guarded against. + mono = tmp_path / "inverted.wav" + method = downmix_to_mono(wav(np.column_stack([voice, -voice])), mono, progress) + assert "inverted" in method + assert np.abs(sf.read(mono)[0] - voice).max() == pytest.approx(0, abs=1e-7) + # One silent channel: keep the live channel at unity gain, not -6 dB. + mono = tmp_path / "silent.wav" + method = downmix_to_mono(wav(np.column_stack([voice, np.zeros_like(voice)])), mono, progress) + assert "right channel is silent" in method + assert np.abs(sf.read(mono)[0] - voice).max() == pytest.approx(0, abs=1e-7) + method = downmix_to_mono(wav(np.column_stack([np.zeros_like(voice), voice])), + tmp_path / "silent-left.wav", progress) + assert "left channel is silent" in method + with pytest.raises(ValueError, match="non-finite"): + downmix_to_mono(wav(np.array([[0.1, np.nan]])), tmp_path / "bad.wav", progress) + + +def test_stereo_input_is_downmixed_not_rejected(wav, tmp_path, bypass, progress, ffmpeg): + time = np.arange(48000 * 3) / 48000 + voice = 0.1 * np.sin(2 * np.pi * 440 * time) + source = wav(np.column_stack([voice, -voice])) + original = source.read_bytes() + target = tmp_path / "master.wav" + report = pipeline.process(source, target, bypass, progress) + assert any("downmixed to mono" in warning and "inverted" in warning + for warning in report["warnings"]) + stats = sf.info(target) + assert stats.channels == 1 and stats.frames == 144000 + # A naive (L + R) / 2 mix of these channels is digital silence; the master + # must carry real signal instead. + assert report["master"]["sample_peak_dbfs"] > -30 + assert source.read_bytes() == original + assert not list(target.parent.glob(".producer-*")) + + def test_measure_reports_channels_peaks_clipping_and_progress(wav): samples = np.tile([1.0, -0.25], (4800, 1)) events = [] |
