diff options
Diffstat (limited to 'lib/project/tests')
| -rw-r--r-- | lib/project/tests/conftest.py | 25 | ||||
| -rw-r--r-- | lib/project/tests/test_cli.py | 52 | ||||
| -rw-r--r-- | lib/project/tests/test_gui.py | 87 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_browser.py | 39 | ||||
| -rw-r--r-- | lib/project/tests/test_gui_browser_races.py | 31 | ||||
| -rw-r--r-- | lib/project/tests/test_pipeline.py | 88 |
6 files changed, 240 insertions, 82 deletions
diff --git a/lib/project/tests/conftest.py b/lib/project/tests/conftest.py index 0155384..9cde05f 100644 --- a/lib/project/tests/conftest.py +++ b/lib/project/tests/conftest.py @@ -12,6 +12,31 @@ from producer.config import resolve from producer.setup import ensure_ffmpeg +@pytest.fixture(autouse=True) +def producer_root(tmp_path, monkeypatch): + """Isolated shared logs directory for every test: report logs always go + under <PRODUCER_ROOT>/logs.""" + root = tmp_path / "producer-root" + monkeypatch.setenv("PRODUCER_ROOT", str(root)) + return root + + +@pytest.fixture +def logs(producer_root): + return producer_root / "logs" + + +@pytest.fixture +def report_of(logs): + """Locate the one published report for an audio destination; report names + are timestamped, so tests glob by stem.""" + def find(target): + matches = sorted(logs.glob(f"{target.stem}.*.report.json")) + assert len(matches) == 1, matches + return matches[0] + return find + + @pytest.fixture def progress(): return lambda *_: None diff --git a/lib/project/tests/test_cli.py b/lib/project/tests/test_cli.py index 05c8292..31eadf9 100644 --- a/lib/project/tests/test_cli.py +++ b/lib/project/tests/test_cli.py @@ -19,7 +19,7 @@ def invoke(monkeypatch): def test_preview_real_audio_preserves_original_and_reports_provenance( - wav, tmp_path, ffmpeg, invoke, capsys, monkeypatch + wav, tmp_path, ffmpeg, invoke, capsys, monkeypatch, report_of ): time = np.arange(48000 * 7) / 48000 source = wav(0.15 * np.sin(2 * np.pi * 440 * time)) @@ -44,13 +44,15 @@ def test_preview_real_audio_preserves_original_and_reports_provenance( assert "Preview:" in captured.err assert source.read_bytes() == original assert len(modes) == 2 + # --no-normalize --no-limiter differ from every built-in profile: the + # outputs are named 'custom', with the compared profile kept for clarity. assert {path.name for path in output_dir.iterdir()} == { - f"input.{profile}.preview.wav" + f"input.custom.{profile}.preview.wav" for profile in ("natural", "radio") - } | {"logs"} + } for profile, mode in zip(("natural", "radio"), modes): - target = output_dir / f"input.{profile}.preview.wav" - report = json.loads(pipeline.report_for(target).read_text()) + target = output_dir / f"input.custom.{profile}.preview.wav" + report = json.loads(report_of(target).read_text()) info = sf.info(target) assert info.duration == pytest.approx(5, abs=0.02) assert info.channels == 1 @@ -123,7 +125,7 @@ def test_missing_input_fails_before_tool_setup(tmp_path, invoke, capsys, monkeyp assert not target.exists() -def test_existing_output_is_not_overwritten(wav, tmp_path, invoke, capsys, monkeypatch): +def test_existing_output_is_not_overwritten(wav, tmp_path, invoke, capsys, monkeypatch, logs): source = wav() original = source.read_bytes() target = tmp_path / "master.wav" @@ -135,7 +137,7 @@ def test_existing_output_is_not_overwritten(wav, tmp_path, invoke, capsys, monke assert "Output exists" in capsys.readouterr().err assert target.read_bytes() == b"previous master" assert source.read_bytes() == original - assert not pipeline.report_for(target).exists() + assert not list(logs.glob("*.report.json")) def test_overwrite_cannot_replace_input(wav, invoke, capsys, monkeypatch): @@ -169,19 +171,23 @@ def test_default_outputs_are_written_beside_each_input(wav, tmp_path, ffmpeg, in sources = [wav(name="a/take.wav"), wav(name="b/take.wav")] invoke("process", *sources, "--no-denoise") for source in sources: + # --no-denoise differs from the built-in profile, so outputs are named + # 'custom'. Report logs never appear beside the outputs. assert {path.name for path in source.parent.iterdir()} == { - "take.wav", "take.natural.wav", "logs"} + "take.wav", "take.custom.wav"} captured = capsys.readouterr() assert captured.out == "" assert captured.err.count("Written:") == 2 -def test_process_keyword_is_optional(wav, tmp_path, ffmpeg, invoke, capsys): +def test_process_keyword_is_optional(wav, tmp_path, ffmpeg, invoke, capsys, report_of): source = wav() original = source.read_bytes() invoke(source, "--no-denoise") assert {path.name for path in tmp_path.iterdir()} == { - "input.wav", "input.natural.wav", "logs"} + "input.wav", "input.custom.wav", "producer-root"} + report = json.loads(report_of(tmp_path / "input.custom.wav").read_text()) + assert report["settings"]["denoiser"] == "none" assert source.read_bytes() == original assert capsys.readouterr().err.count("Written:") == 1 @@ -189,7 +195,7 @@ def test_process_keyword_is_optional(wav, tmp_path, ffmpeg, invoke, capsys): def test_leading_flag_still_selects_process(wav, tmp_path, ffmpeg, invoke, capsys): source = wav() invoke("--no-denoise", source) - assert (tmp_path / "input.natural.wav").exists() + assert (tmp_path / "input.custom.wav").exists() def test_output_and_output_dir_cannot_be_combined(wav, tmp_path, invoke, capsys): @@ -206,20 +212,20 @@ def test_missing_later_input_fails_before_any_processing(wav, tmp_path, invoke, invoke("process", source, tmp_path / "missing.wav", "--no-denoise") assert error.value.code == 1 assert "existing WAV file" in capsys.readouterr().err - assert not (tmp_path / "input.natural.wav").exists() + assert not (tmp_path / "input.custom.wav").exists() def test_existing_output_in_batch_fails_before_any_processing(wav, tmp_path, invoke, capsys, monkeypatch): (tmp_path / "a").mkdir() (tmp_path / "b").mkdir() sources = [wav(name="a/take.wav"), wav(name="b/take.wav")] - (tmp_path / "b" / "take.natural.wav").write_bytes(b"previous result") + (tmp_path / "b" / "take.custom.wav").write_bytes(b"previous result") monkeypatch.setattr(cli, "process", lambda *a, **kw: pytest.fail("Preflight must reject the batch")) with pytest.raises(SystemExit) as error: invoke("process", *sources, "--no-denoise") assert error.value.code == 1 assert "Output exists" in capsys.readouterr().err - assert not (tmp_path / "a" / "take.natural.wav").exists() + assert not (tmp_path / "a" / "take.custom.wav").exists() def test_preview_rejects_duplicate_profiles_and_profile_override(wav, invoke, capsys, monkeypatch): @@ -237,15 +243,16 @@ def test_preview_rejects_duplicate_profiles_and_profile_override(wav, invoke, ca assert list(source.parent.iterdir()) == [source] -def test_preview_default_output_is_written_beside_input(wav, tmp_path, ffmpeg, invoke, capsys): +def test_preview_default_output_is_written_beside_input(wav, tmp_path, ffmpeg, invoke, capsys, report_of): source = wav() original = source.read_bytes() invoke("preview", source, "--no-denoise", "--start", "1", "--duration", "2", "--profiles", "natural") assert {path.name for path in tmp_path.iterdir()} == { - "input.wav", "input.natural.preview.wav", "logs"} - report = json.loads(pipeline.report_for(tmp_path / "input.natural.preview.wav").read_text()) + "input.wav", "input.custom.natural.preview.wav", "producer-root"} + report = json.loads(report_of(tmp_path / "input.custom.natural.preview.wav").read_text()) assert report["settings"]["profile"] == "natural" + assert report["settings"]["denoiser"] == "none" assert source.read_bytes() == original @@ -271,14 +278,16 @@ def test_preview_cancellation_cleans_excerpt(wav, tmp_path, ffmpeg, invoke, caps assert source.read_bytes() == original -@pytest.mark.parametrize("suffix", [".wav", ".report.json", ".mp3"]) +@pytest.mark.parametrize("suffix", [".wav", ".mp3"]) def test_nonfile_output_with_overwrite_fails_before_tool_setup( wav, tmp_path, bypass, progress, monkeypatch, suffix ): + # Report logs are timestamped and uniquified in the shared logs directory, + # so only audio outputs can collide with a non-file. source = wav() original = source.read_bytes() target = tmp_path / "master.wav" - nonfile = target.with_suffix(suffix) if suffix != ".report.json" else pipeline.report_for(target) + nonfile = target.with_suffix(suffix) nonfile.parent.mkdir(parents=True, exist_ok=True) nonfile.mkdir() bypass.mp3 = True @@ -287,8 +296,5 @@ def test_nonfile_output_with_overwrite_fails_before_tool_setup( pipeline.process(source, target, bypass, progress, overwrite=True) assert nonfile.is_dir() assert list(nonfile.iterdir()) == [] - if suffix == ".report.json": - assert {path.name for path in tmp_path.iterdir()} == {source.name, "logs"} - else: - assert set(tmp_path.iterdir()) == {source, nonfile} + assert set(tmp_path.iterdir()) == {source, nonfile} assert source.read_bytes() == original diff --git a/lib/project/tests/test_gui.py b/lib/project/tests/test_gui.py index 2f17549..83a8220 100644 --- a/lib/project/tests/test_gui.py +++ b/lib/project/tests/test_gui.py @@ -13,7 +13,7 @@ import soundfile as sf pytest.importorskip("fastapi") from fastapi.testclient import TestClient -from producer.config import Settings +from producer.config import Settings, resolve from producer import gui as gui_module from producer.gui import create_app from producer import gui_jobs @@ -24,20 +24,21 @@ URL = "http://127.0.0.1:8765" @pytest.fixture def client(tmp_path): with TestClient(create_app(8765, output_dir=tmp_path), base_url=URL) as client: + # Per-server upload cache; module-global caches would leak stale + # upload ids across tests (id() of dead Path objects gets reused). + client._upload_cache = {} 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: + cache = client._upload_cache + key = (str(path), name) + if key not in cache: 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] + cache[key] = response.json() + return cache[key] def wait(client, job_id): @@ -200,16 +201,20 @@ def test_real_preview_context_exact_crop_bypass_and_export_trim(client, tmp_path 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, - 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 Path(path).is_absolute() and Path(path).is_file() - assert len(list(tmp_path.glob("producer-export-*"))) == 2 + 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"] + # Audio directly in the output directory; the timestamped report in + # the shared logs directory. No producer-export-* folders anywhere. + for path in exported["result"]["paths"]: + assert Path(path).is_absolute() and Path(path).is_file() + assert Path(exported["result"]["paths"][0]) == tmp_path / "input.custom.wav" + assert Path(exported["result"]["paths"][1]) == tmp_path / "input.custom.mp3" + report = Path(exported["result"]["paths"][2]) + assert report.parent.name == "logs" and report.name.startswith("input.custom.") assert hashlib.sha256(source.read_bytes()).hexdigest() == before @@ -221,6 +226,12 @@ def test_export_output_directory_rules(client, wav, bypass, tmp_path): "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 + assert client.post("/api/jobs", json={**base, "overwrite": "yes"}).status_code == 400 + assert client.post("/api/export/check", + json={**base, "input_id": "stale"}).status_code == 400 + assert client.post("/api/export/check", + json={"upload_id": entry["upload_id"], "input_id": entry["input_id"], + "settings": requested, "output_dir": "relative"}).status_code == 400 missing = client.post("/api/jobs", json={**base}) assert missing.status_code == 202 job = wait(client, missing.json()["id"]) @@ -229,6 +240,46 @@ def test_export_output_directory_rules(client, wav, bypass, tmp_path): assert Path(path).is_absolute() and Path(path).is_file() Path(path).relative_to(tmp_path) + +def test_export_check_exists_and_overwrite_replaces_audio(client, wav, bypass, tmp_path, logs): + source = wav() + entry = upload(client, source) + requested = asdict(bypass) + body = {"upload_id": entry["upload_id"], "input_id": entry["input_id"], + "settings": requested, "output_dir": str(tmp_path)} + check = client.post("/api/export/check", json=body) + assert check.status_code == 200 + assert check.json() == {"exists": False, "paths": [str(tmp_path / "input.custom.wav")]} + first = wait(client, submit(client, source, "export", settings=requested, + output_dir=str(tmp_path))["id"]) + assert first["status"] == "succeeded", first + assert first["result"]["paths"][0] == str(tmp_path / "input.custom.wav") + assert client.post("/api/export/check", json=body).json() == { + "exists": True, "paths": [str(tmp_path / "input.custom.wav")]} + # Without confirmation the export refuses to replace the audio; with the + # browser-confirmed overwrite flag it replaces it. + refused = wait(client, submit(client, source, "export", settings=requested, + output_dir=str(tmp_path))["id"]) + assert refused["status"] == "failed" and "Output exists" in refused["error"] + replaced = wait(client, submit(client, source, "export", settings=requested, + output_dir=str(tmp_path), overwrite=True)["id"]) + assert replaced["status"] == "succeeded", replaced + assert [path for path in tmp_path.glob("input.custom.*")] == [tmp_path / "input.custom.wav"] + # Reports are timestamped, so both exports left one report each. + assert len(list(logs.glob("input.custom.*.report.json"))) == 2 + + +def test_export_name_matches_pure_builtin_profile(client, wav, tmp_path): + # Naming comes from the server: settings exactly equal to a built-in + # profile keep its name; anything else is 'custom' (no processing needed). + source = wav() + entry = upload(client, source) + pure = asdict(resolve(profile="natural")) + body = {"upload_id": entry["upload_id"], "input_id": entry["input_id"], + "settings": pure, "output_dir": str(tmp_path)} + check = client.post("/api/export/check", json=body) + assert check.json() == {"exists": False, "paths": [str(tmp_path / "input.natural.wav")]} + def test_preview_bounds_and_empty_inputs(client, wav): source = wav() entry = upload(client, source) diff --git a/lib/project/tests/test_gui_browser.py b/lib/project/tests/test_gui_browser.py index 5edc114..31a7c37 100644 --- a/lib/project/tests/test_gui_browser.py +++ b/lib/project/tests/test_gui_browser.py @@ -70,7 +70,7 @@ def test_gui_browser_real_workflow(studio): 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"]) + browser = playwright.chromium.launch(headless=True, channel=os.environ.get("PRODUCER_BROWSER_CHANNEL") or None, args=["--no-sandbox"]) context = browser.new_context(viewport={"width": 1360, "height": 1000}, accept_downloads=True) page = context.new_page() errors, console_errors, media_requests = [], [], [] @@ -173,22 +173,31 @@ def test_gui_browser_real_workflow(studio): 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() - 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() + # Audio is written directly into the chosen output directory; no + # pacing dialog exists anymore, and nothing exists to overwrite yet. 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() + assert (studio["root"] / "delivery" / "Narration.custom.wav").is_file() + assert (studio["root"] / "delivery" / "Narration.custom.mp3").is_file() + reports = list((studio["root"] / "producer-root" / "logs").glob("Narration.custom.*.report.json")) + assert len(reports) == 1 import soundfile as sf - assert sf.info(outputs[0] / "master.wav").duration == pytest.approx(8, abs=.01) + assert sf.info(studio["root"] / "delivery" / "Narration.custom.wav").duration == pytest.approx(8, abs=.01) assert len(media_requests) == preview_requests, "Full exports must not be fetched into browser blobs" + # A second export targets the same audio file: the browser asks, and + # the red Overwrite confirmation replaces it. 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 + expect(page.locator("#overwrite-dialog")).to_be_visible() + expect(page.locator("#overwrite-paths")).to_contain_text("Narration.custom.wav") + page.locator("#overwrite-confirm").click() + # Only the latest export stays on display; a new one replaces the card. + expect(page.locator("#export-results .export-result")).to_have_count(1, timeout=30000) + # The replaced export also leaves its own timestamped report. + deadline = time.monotonic() + 30 + while time.monotonic() < deadline and len(list((studio["root"] / "producer-root" / "logs").glob("Narration.custom.*.report.json"))) < 2: + time.sleep(0.2) + assert (studio["root"] / "delivery" / "Narration.custom.wav").is_file() + assert not list((studio["root"] / "delivery").glob("producer-export-*")) + assert len(list((studio["root"] / "producer-root" / "logs").glob("Narration.custom.*.report.json"))) == 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) @@ -198,7 +207,7 @@ def test_gui_browser_real_workflow(studio): 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) + expect(page.locator("#export-results .export-result")).to_have_count(1, 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)") @@ -214,7 +223,7 @@ 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"]) + browser = playwright.chromium.launch(headless=True, channel=os.environ.get("PRODUCER_BROWSER_CHANNEL") or None, args=["--no-sandbox"]) page = browser.new_page(viewport={"width": 1200, "height": 900}) requests, errors = [], [] page.on("request", lambda request: requests.append(request.url)) diff --git a/lib/project/tests/test_gui_browser_races.py b/lib/project/tests/test_gui_browser_races.py index 5f2638b..32b7eef 100644 --- a/lib/project/tests/test_gui_browser_races.py +++ b/lib/project/tests/test_gui_browser_races.py @@ -13,7 +13,7 @@ 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"]) + browser = playwright.chromium.launch(headless=True, channel=os.environ.get("PRODUCER_BROWSER_CHANNEL") or None, args=["--no-sandbox"]) page = browser.new_page() page.goto(studio["url"]) page.locator("#pick-files").set_input_files(studio["source"]) @@ -22,6 +22,13 @@ def test_gui_browser_cancellation_is_not_pretend(studio): 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) + # The launch override disables denoising, hiding its strength control; + # enable the denoiser module (checkbox), then pick FFT and edit + # another control while the preview is running. + page.locator("#enable-denoiser").check() + expect(page.locator("#setting-denoiser")).to_be_enabled() + page.locator("#setting-denoiser").select_option("fft") + expect(page.locator("#setting-denoise_strength")).to_be_visible() page.locator("#setting-denoise_strength").fill("0.4") page.locator("#render-preview").click() queued = page.locator("#jobs .job").filter(has_text="queued") @@ -42,7 +49,7 @@ 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"]) + browser = playwright.chromium.launch(headless=True, channel=os.environ.get("PRODUCER_BROWSER_CHANNEL") or None, 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); @@ -92,7 +99,7 @@ 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"]) + browser = playwright.chromium.launch(headless=True, channel=os.environ.get("PRODUCER_BROWSER_CHANNEL") or None, args=["--no-sandbox"]) page = browser.new_page() page.add_init_script("""window.monitorGains = []; const createGain = AudioContext.prototype.createGain; @@ -117,13 +124,11 @@ def test_gui_browser_nonzero_monitoring_gain_is_playback_only(studio): 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 + # The old pacing export dialog is gone; export submits directly and + # nothing exists in the output directory to overwrite. + page.locator("#output-dir").fill(str(studio["root"] / "delivery")) 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() + expect(page.locator("#export-results")).to_contain_text("Full recording exported", timeout=30000) page.locator("#match").uncheck() assert page.evaluate("monitorGains.map(node => node.gain.value)") == [1, 1] browser.close() @@ -134,7 +139,7 @@ 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"]) + browser = playwright.chromium.launch(headless=True, channel=os.environ.get("PRODUCER_BROWSER_CHANNEL") or None, args=["--no-sandbox"]) page = browser.new_page() page.goto(studio["url"]) if kind == "preview": @@ -176,7 +181,7 @@ def test_delayed_audio_resume_does_not_start_superseded_playback(studio, interru from playwright.sync_api import sync_playwright, expect with sync_playwright() as playwright: - browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"]) + browser = playwright.chromium.launch(headless=True, channel=os.environ.get("PRODUCER_BROWSER_CHANNEL") or None, args=["--no-sandbox"]) page = browser.new_page() page.add_init_script("""window.releaseAudioResume = null; const resume = AudioContext.prototype.resume; @@ -196,6 +201,10 @@ def test_delayed_audio_resume_does_not_start_superseded_playback(studio, interru page.locator("#listen-processed").click() expect(page.locator("#listen-processed")).to_have_attribute("aria-pressed", "true") elif interruption == "edit": + # The launch override hides the denoiser module; enable it and + # pick FFT so the strength control is editable. + page.locator("#enable-denoiser").check() + page.locator("#setting-denoiser").select_option("fft") page.locator("#setting-denoise_strength").fill("0.4") else: page.route("**/api/jobs", lambda route: route.fulfill( diff --git a/lib/project/tests/test_pipeline.py b/lib/project/tests/test_pipeline.py index 0bc2cef..fc40a82 100644 --- a/lib/project/tests/test_pipeline.py +++ b/lib/project/tests/test_pipeline.py @@ -1,4 +1,7 @@ +import errno import json +import os +from pathlib import Path import numpy as np import pytest @@ -51,14 +54,18 @@ def test_existing_output_or_sidecar_rejected_before_processing(wav, tmp_path, by assert existing.read_bytes() == b"existing output" -def test_existing_report_log_is_replaced_without_overwrite(wav, tmp_path, bypass, progress, ffmpeg): +def test_existing_report_log_is_kept_and_new_report_published(wav, tmp_path, bypass, progress, ffmpeg, logs): source = wav() target = tmp_path / "master.wav" - existing = pipeline.report_for(target) - existing.parent.mkdir(parents=True, exist_ok=True) - existing.write_bytes(b"stale report") + logs.mkdir(parents=True, exist_ok=True) + stale = logs / "master.20240101-000000.report.json" + stale.write_bytes(b"stale report") report = pipeline.process(source, target, bypass, progress) - assert json.loads(existing.read_text()) == report + # Timestamped reports never replace earlier ones. + assert stale.read_bytes() == b"stale report" + published = Path(report["report"]) + assert published.parent == logs and published != stale + assert json.loads(published.read_text()) == report @pytest.mark.parametrize("kind,message", [ @@ -139,7 +146,7 @@ def test_measure_reports_channels_peaks_clipping_and_progress(wav): @pytest.mark.parametrize("rate", [44100, 48000]) -def test_normalization_peak_ceiling_and_report(wav, tmp_path, progress, ffmpeg, rate): +def test_normalization_peak_ceiling_and_report(wav, tmp_path, progress, ffmpeg, rate, report_of): source = wav() original = source.read_bytes() target = tmp_path / "nested" / "master.wav" @@ -155,7 +162,7 @@ def test_normalization_peak_ceiling_and_report(wav, tmp_path, progress, ffmpeg, assert verified["input_tp"] <= settings.true_peak_db + 0.1 assert report["master"]["near_full_scale_samples"] == 0 assert report["master"]["sample_peak_dbfs"] <= settings.true_peak_db + 0.1 - assert json.loads(pipeline.report_for(target).read_text()) == report + assert json.loads(report_of(target).read_text()) == report assert source.read_bytes() == original assert not list(target.parent.glob(".producer-*")) @@ -216,32 +223,37 @@ def test_zero_denoise_strength_skips_ai(wav, tmp_path, bypass, progress, ffmpeg, assert report["cleaned"]["frames"] == report["input"]["frames"] -def test_fft_and_mp3_export_can_replace_generated_outputs(wav, tmp_path, bypass, progress, ffmpeg): +def test_fft_and_mp3_export_can_replace_generated_outputs(wav, tmp_path, bypass, progress, ffmpeg, logs): source = wav() original = source.read_bytes() bypass.denoiser = "fft" bypass.mp3 = True target = tmp_path / "master.wav" - for path in (target, pipeline.report_for(target), target.with_suffix(".mp3")): - path.parent.mkdir(parents=True, exist_ok=True) + logs.mkdir(parents=True, exist_ok=True) + stale_report = logs / "master.20240101-000000.report.json" + for path in (target, target.with_suffix(".mp3"), stale_report): path.write_bytes(b"old output") events = [] report = pipeline.process(source, target, bypass, lambda *event: events.append(event), overwrite=True) assert any(event[0] == "FFT noise reduction" for event in events) assert target.with_suffix(".mp3").stat().st_size > 1000 assert report["mp3_loudness"]["input_i"] is not None - assert json.loads(pipeline.report_for(target).read_text()) == report + # The old report stays; the new one is timestamped and returned. + assert stale_report.read_bytes() == b"old output" + assert json.loads(Path(report["report"]).read_text()) == report + assert Path(report["report"]).parent == logs assert sf.info(target).channels == 1 assert source.read_bytes() == original -def test_render_failure_preserves_existing_outputs_and_removes_temporary_files(wav, tmp_path, bypass, progress, ffmpeg, monkeypatch): +def test_render_failure_preserves_existing_outputs_and_removes_temporary_files(wav, tmp_path, bypass, progress, ffmpeg, monkeypatch, logs): source = wav() target = tmp_path / "master.wav" bypass.mp3 = True - outputs = [target, pipeline.report_for(target), target.with_suffix(".mp3")] - for path in outputs: - path.parent.mkdir(parents=True, exist_ok=True) + outputs = [target, target.with_suffix(".mp3")] + logs.mkdir(parents=True, exist_ok=True) + stale_report = logs / "master.20240101-000000.report.json" + for path in outputs + [stale_report]: path.write_bytes(b"previous publication") real_run = pipeline.ffmpeg_run @@ -254,6 +266,7 @@ def test_render_failure_preserves_existing_outputs_and_removes_temporary_files(w with pytest.raises(RuntimeError, match="injected encoder failure"): pipeline.process(source, target, bypass, progress, overwrite=True) assert all(path.read_bytes() == b"previous publication" for path in outputs) + assert stale_report.read_bytes() == b"previous publication" assert not list(tmp_path.glob(".producer-*")) @@ -283,6 +296,51 @@ def test_publish_new_output_moves_staged_file(tmp_path): assert not staged.exists() +def test_publish_across_filesystems_falls_back_to_copy(tmp_path, monkeypatch): + """Audio outputs and the shared logs directory can be on different mounts: + publication then copies atomically into the target directory instead of + failing with EXDEV.""" + real_link, real_replace = os.link, os.replace + + def refuse_cross_dir(src, dst=None): + src, dst = Path(src), Path(dst) + # Simulate two mounts: links and renames only work within one parent. + if dst.parent != src.parent: + raise OSError(errno.EXDEV, os.strerror(errno.EXDEV)) + return real_link(src, dst) + + def refuse_cross_rename(src, dst): + src, dst = Path(src), Path(dst) + if dst.parent != src.parent: + raise OSError(errno.EXDEV, os.strerror(errno.EXDEV)) + return real_replace(src, dst) + + monkeypatch.setattr(os, "link", refuse_cross_dir) + monkeypatch.setattr(os, "replace", refuse_cross_rename) + outgoing = tmp_path / "out" + incoming = tmp_path / "in" + outgoing.mkdir() + incoming.mkdir() + # Non-overwrite: copy path must still refuse an existing destination. + staged = outgoing / "report.json" + staged.write_bytes(b"new") + (incoming / "report.json").write_bytes(b"old") + with pytest.raises(FileExistsError): + pipeline.publish(staged, incoming / "report.json", overwrite=False) + assert (incoming / "report.json").read_bytes() == b"old" + # Successful non-overwrite publication moves the staged file. + fresh = outgoing / "fresh.json" + fresh.write_bytes(b"new") + pipeline.publish(fresh, incoming / "fresh.json", overwrite=False) + assert (incoming / "fresh.json").read_bytes() == b"new" + assert not list(incoming.glob(".fresh.json*")) and not fresh.exists() + # Overwrite replaces the existing destination. + staged.write_bytes(b"newer") + pipeline.publish(staged, incoming / "report.json", overwrite=True) + assert (incoming / "report.json").read_bytes() == b"newer" + assert not list(incoming.glob(".report.json*")) and not staged.exists() + + def test_trim_gaps_shortens_silence_and_keeps_speech_exact(wav, tmp_path, progress): from producer.audio import trim_gaps sr = 48000 |
