import os import pytest # Redirect QSettings to a throwaway dir BEFORE any MainWindow is constructed, so # these GUI tests can never read or clobber the user's real ~/.config/8cut.conf # (constructing MainWindow loads — and on window close re-saves — the playlist # tabs; a test mutating tab state would otherwise persist into the real session). import tempfile as _tempfile from PyQt6.QtCore import QSettings as _QSettings _QS_DIR = _tempfile.mkdtemp(prefix="8cut-test-qs-") _QSettings.setPath(_QSettings.Format.NativeFormat, _QSettings.Scope.UserScope, _QS_DIR) _QSettings.setPath(_QSettings.Format.IniFormat, _QSettings.Scope.UserScope, _QS_DIR) # A real platform is needed because MpvWidget creates a GL context. # If construction fails for any environment reason, skip — this test is a # best-effort structural net, not a gate on core/ tests. pytestmark = pytest.mark.gui @pytest.fixture(scope="module") def app(): from PyQt6.QtWidgets import QApplication inst = QApplication.instance() or QApplication([]) yield inst @pytest.fixture def win(app): try: from main import MainWindow w = MainWindow() except Exception as e: # GL/mpv/display unavailable, etc. pytest.skip(f"MainWindow could not be constructed here: {e}") # Deterministic deck state regardless of any persisted side-by-side layout # (construction restores deck_pinned from QSettings). for _p in w._deck_panels: _p._pinned = False w._refresh_deck_layout() yield w w.close() w.deleteLater() def test_window_constructs(win): assert win.windowTitle().startswith("8-cut") def test_status_bar_exists(win): assert win.statusBar() is not None def test_workers_spinbox_in_export_tab(win): from PyQt6.QtWidgets import QSpinBox assert win._spn_workers in win._tab_export.findChildren(QSpinBox) def test_scan_button_in_audio_tab(win): from PyQt6.QtWidgets import QPushButton assert win._btn_scan in win._tab_audio.findChildren(QPushButton) def test_portrait_combo_in_crop_tab(win): from PyQt6.QtWidgets import QComboBox assert win._cmb_portrait in win._tab_crop.findChildren(QComboBox) def test_menu_only_buttons_not_in_deck(win): from PyQt6.QtWidgets import QPushButton deck_btns = win._control_deck.findChildren(QPushButton) assert win._btn_train not in deck_btns assert win._btn_scan_all not in deck_btns assert win._btn_hide_subcats not in deck_btns def test_deck_stack_exists(win): # The deck is wrapped in a stack so it can swap tabbed <-> side-by-side. # Default (nothing pinned) shows the tabbed control deck. assert win._deck_stack is not None assert win._deck_stack.currentWidget() is win._control_deck def _split_columns(win): """Widgets of the splitter actually mounted in the layout (not findChild, which can return a stale deleteLater'd splitter).""" from PyQt6.QtWidgets import QSplitter item = win._deck_split_layout.itemAt(0) spl = item.widget() if item else None assert isinstance(spl, QSplitter) return [spl.widget(i) for i in range(spl.count())] def test_pinning_two_panels_shows_exactly_two_columns(win): # Pin two panels directly (avoid the toggle handler so no QSettings write # leaks into other test windows) and refresh. from PyQt6.QtWidgets import QTabWidget win._tab_export._pinned = True win._tab_crop._pinned = True win._refresh_deck_layout() assert win._deck_stack.currentWidget() is win._deck_split_container cols = _split_columns(win) assert len(cols) == 2 # only the pinned ones assert not any(isinstance(c, QTabWidget) for c in cols) # no leftover tab-column def test_side_by_side_menu_pins_third_panel(win): # In split mode the View ▸ Side-by-side menu is the way to pin a 3rd panel # (there's no tab bar to right-click). Suppress the QSettings save via the # _deck_loading guard so this doesn't leak into other windows. win._tab_export._pinned = True win._tab_audio._pinned = True win._refresh_deck_layout() assert len(_split_columns(win)) == 2 act = next(a for a, p in win._deck_pin_actions if p is win._tab_crop) win._deck_loading = True # suppress _save_deck_layout try: act.trigger() # simulate clicking the menu item finally: win._deck_loading = False assert win._tab_crop._pinned is True assert len(_split_columns(win)) == 3 def test_duplicate_tab(win): # Right-click → Duplicate tab: clones files into a new tab with an adapted # name + adapted own folder, no file moves. Suppress QSettings writes via # _loading_tabs so the test can't touch the real session. win._loading_tabs = True try: src = win._pws[0] src._label = "AlexisCrystal" src._dest_folder = "/data/alexis/" # trailing slash, like real folders n_before = len(win._pws) win._on_duplicate_tab(win._playlist_tabs.indexOf(src)) finally: win._loading_tabs = False assert len(win._pws) == n_before + 1 dup = win._pws[-1] assert dup._label == "AlexisCrystal copy" # sibling, not a child: ".../alexis/" -> ".../alexis_copy" (not ".../alexis/_copy") assert dup._dest_folder == "/data/alexis_copy" def test_tab_mode_defaults_foley(win): # Fresh tabs use the Foley pipeline; sessions/tabs without a stored mode # load unchanged. assert win._pws for pw in win._pws: assert pw._mode == "foley" def test_tab_mode_toggle(win): # Right-click → "LTX-2 mode" flips the per-tab mode and the displayed title # gains a [LTX2] badge (without mutating pw._label). Suppress QSettings # writes via _loading_tabs so the test can't touch the real session. win._loading_tabs = True try: win._on_tab_mode_toggle(win._playlist_tabs.indexOf(win._pws[0])) finally: win._loading_tabs = False assert win._pws[0]._mode == "ltx2" assert win._tab_title(win._pws[0]).endswith("[LTX2]") def test_ltx2_params_none_for_foley(win): # A Foley tab feeds no LTX-2 ffmpeg params into export. Set the mode # explicitly: a prior test's closeEvent can persist an ltx2 tab into the # shared (throwaway) QSettings, so don't rely on the loaded default here. win._playlist._mode = "foley" assert win._ltx2_export_params() is None def test_ltx2_params_for_ltx2_tab(win): # An ltx2-mode active tab: _ltx2_export_params returns the 25fps / ÷32 / # exact-frames kwargs, and _apply_mode_to_controls swaps the length control # (Duration hidden, frames shown). short_side defaults to 512 when unset. win._spn_resize.setValue(0) # force the 512 LTX-2 default path win._pws[0]._mode = "ltx2" win._active_pw = win._pws[0] win._playlist_tabs.setCurrentWidget(win._pws[0]) win._spn_frames.setValue(201) win._apply_mode_to_controls() assert win._ltx2_export_params() == { "target_fps": 25.0, "snap32": True, "frames": 201, "duration": 201 / 25, "short_side": 512, } # In offscreen, isVisibleTo(win) may be False for both; assert via the # show/hide flag that the Duration control is hidden in ltx2 mode. assert win._spn_clip_dur.isHidden() assert not win._spn_frames.isHidden() def test_duplicate_preserves_ltx2_mode(win): # Duplicating an LTX-2 tab must yield an LTX-2 tab (mode is copied alongside # the folder fields). Suppress QSettings writes via _loading_tabs. win._loading_tabs = True try: src = win._pws[0] src._mode = "ltx2" win._on_duplicate_tab(win._playlist_tabs.indexOf(src)) finally: win._loading_tabs = False dup = win._pws[-1] assert dup._mode == "ltx2" def test_frames_snaps_to_legal(win): # A typed (illegal) frame count snaps to the nearest legal 8k+1 value so the # displayed value == the exported value and is always a valid LTX-2 clip. win._spn_frames.setValue(100) win._snap_frames_to_legal() # the editingFinished slot assert win._spn_frames.value() == 97 # nearest 8k+1 to 100 assert (win._spn_frames.value() - 1) % 8 == 0 def test_export_base_name_handles_trailing_slash(win): # A folder ending in "/" must still yield the real base name, else # subprofile naming breaks ("_blowjob" instead of "mp4_blowjob"). win._txt_folder.setText("/x/AlexisCrystal/mp4/") assert win._export_base_name() == "mp4" win._txt_folder.setText("/x/AlexisCrystal/mp4") assert win._export_base_name() == "mp4" def test_subprofile_button_visibility_exact_match(win): # A subcategory's export button must track ITS folder exactly. A ghost # "_blowjob" (empty-base leftover) or an unrelated "mp4_no_clap" must NOT # hide the "blowjob"/"clap" buttons (the old fuzzy endswith() match did, # so enabling a subcategory never revealed its export button). win._txt_folder.setText("/x/AlexisCrystal/mp4") win._subprofiles = ["blowjob", "clap"] win._rebuild_subprofile_buttons() btns = {b.text().removeprefix("▸ "): b for b in win._subprofile_btns} win._hidden_subcats = {"_blowjob", "mp4_no_clap"} win._apply_subcat_visibility() assert not btns["blowjob"].isHidden() # ghost "_blowjob" must not hide it assert not btns["clap"].isHidden() # "mp4_no_clap" must not hide "clap" win._hidden_subcats = {"mp4_blowjob"} # exact folder -> hidden win._apply_subcat_visibility() assert btns["blowjob"].isHidden() assert not btns["clap"].isHidden() def test_extract_audio_controls_exist(win): from PyQt6.QtWidgets import QPushButton, QDoubleSpinBox assert isinstance(win._btn_extract_audio, QPushButton) assert isinstance(win._spn_audio_len, QDoubleSpinBox) # Disabled until a file is loaded. assert not win._btn_extract_audio.isEnabled() # Arrows step by 1s and there's no practical upper cap (long audio areas). assert win._spn_audio_len.singleStep() == 1.0 assert win._spn_audio_len.maximum() >= 3600.0 def test_audio_region_tracks_cursor_and_length(win): # The teal audio band spans [cursor, cursor + length]; changing the length # or moving the cursor moves the band. Fake a loaded file so the guard in # _update_audio_region passes. win._file_path = "/x/video.mp4" win._cursor = 10.0 win._spn_audio_len.setValue(4.0) # fires _on_audio_len_changed assert win._timeline._audio_region == (10.0, 14.0) win._cursor = 20.0 win._update_audio_region() assert win._timeline._audio_region == (20.0, 24.0) # No file -> band cleared. win._file_path = "" win._update_audio_region() assert win._timeline._audio_region is None def test_timeline_audio_mode_flag(win): tl = win._timeline tl.set_audio_mode(True) assert tl._audio_mode is True tl.set_audio_mode(False) assert tl._audio_mode is False def test_timeline_audio_band_drag_move(win): tl = win._timeline # The band is now wired to the window; fake a loaded file so the window's # _update_audio_region keeps the region instead of clearing it on release. win._file_path = "/x/v.mp4" tl._duration = 20.0 tl._view_start = 0.0; tl._view_span = 20.0 # full view tl.resize(400, tl.height() or 80) tl.set_audio_mode(True) tl.set_audio_region(4.0, 8.0) # [4,8] got = [] tl.audio_region_changed.connect(lambda s, e: got.append((s, e))) # grab the middle of the band and drag it +2s mid_t = 6.0 tl._audio_begin_drag_at_x(tl._time_to_x(mid_t)) tl._audio_drag_to_x(tl._time_to_x(mid_t + 2.0)) tl._audio_end_drag() assert got, "audio_region_changed should fire on release" s, e = tl._audio_region assert abs((e - s) - 4.0) < 0.1 and s > 4.0 # same width, moved right tl.set_audio_mode(False) def test_timeline_audio_band_resize_right(win): tl = win._timeline # Fake a loaded file so the window keeps the region on release (see above). win._file_path = "/x/v.mp4" tl._duration = 20.0; tl._view_start = 0.0; tl._view_span = 20.0 tl.resize(400, tl.height() or 80) tl.set_audio_mode(True) tl.set_audio_region(4.0, 8.0) tl._audio_begin_drag_at_x(tl._time_to_x(8.0)) # grab right edge tl._audio_drag_to_x(tl._time_to_x(10.0)) # drag to 10 tl._audio_end_drag() s, e = tl._audio_region assert abs(s - 4.0) < 0.1 and abs(e - 10.0) < 0.2 tl.set_audio_mode(False) def test_timeline_audio_click_outside_moves_region_start(win): tl = win._timeline win._file_path = "/x/v.mp4" tl._duration = 20.0 tl._view_start = 0.0 tl._view_span = 20.0 tl.resize(400, tl.height() or 80) tl.set_audio_mode(True) tl.set_audio_region(4.0, 8.0) got = [] tl.audio_region_changed.connect(lambda s, e: got.append((s, e))) tl._audio_begin_drag_at_x(tl._time_to_x(12.0)) tl._audio_end_drag() assert got[-1] == tl._audio_region s, e = tl._audio_region assert abs(s - 12.0) < 0.1 assert abs((e - s) - 4.0) < 0.1 def test_timeline_audio_drag_empty_creates_region(win): tl = win._timeline win._file_path = "/x/v.mp4" tl._duration = 20.0 tl._view_start = 0.0 tl._view_span = 20.0 tl.resize(400, tl.height() or 80) tl.set_audio_mode(True) tl.set_audio_region(4.0, 8.0) tl._audio_begin_drag_at_x(tl._time_to_x(12.0)) tl._audio_drag_to_x(tl._time_to_x(15.0)) tl._audio_end_drag() s, e = tl._audio_region assert abs(s - 12.0) < 0.1 assert abs(e - 15.0) < 0.1 def test_timeline_audio_hover_cursor_state(win): tl = win._timeline tl._duration = 20.0 tl._view_start = 0.0 tl._view_span = 20.0 tl.resize(400, tl.height() or 80) tl.set_audio_mode(True) tl.set_audio_region(4.0, 8.0) assert tl._audio_hit_at_x(tl._time_to_x(4.0)) == "left" assert tl._audio_hit_at_x(tl._time_to_x(8.0)) == "right" assert tl._audio_hit_at_x(tl._time_to_x(6.0)) == "move" assert tl._audio_hit_at_x(tl._time_to_x(12.0)) == "create" def test_timeline_audio_mode_lock_click_scrubs_playhead_not_region(win): from PyQt6.QtCore import QEvent, QPointF, Qt from PyQt6.QtGui import QMouseEvent tl = win._timeline tl._duration = 20.0 tl._view_start = 0.0 tl._view_span = 20.0 tl.resize(400, tl.height() or 80) tl.set_audio_mode(True) tl.set_audio_region(4.0, 8.0) tl.set_cursor(4.0) tl._locked = True got_seek = [] got_audio = [] tl.seek_changed.connect(lambda t: got_seek.append(t)) tl.audio_region_changed.connect(lambda s, e: got_audio.append((s, e))) x = tl._time_to_x(12.0) y = tl._SCROLLBAR_H + tl._RULER_H + 20 press = QMouseEvent( QEvent.Type.MouseButtonPress, QPointF(x, y), Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier) release = QMouseEvent( QEvent.Type.MouseButtonRelease, QPointF(x, y), Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier) tl.mousePressEvent(press) tl.mouseReleaseEvent(release) assert abs((tl._play_pos or 0.0) - 12.0) < 0.1 assert tl._audio_region == (4.0, 8.0) assert not got_audio assert got_seek and abs(got_seek[-1] - 12.0) < 0.1 def test_timeline_lock_mode_paints_playhead_line(win): from PyQt6.QtGui import QColor, QImage tl = win._timeline tl._duration = 20.0 tl._view_start = 0.0 tl._view_span = 20.0 tl.resize(400, 160) tl.set_audio_mode(False) tl.set_clip_span(8.0, 8.0, 3.0) tl.set_cursor(4.0) tl._locked = True tl.set_play_position(7.0) img = QImage(tl.size(), QImage.Format.Format_ARGB32) img.fill(QColor(0, 0, 0)) tl.render(img) x = int(tl._time_to_x(7.0)) y = tl._SCROLLBAR_H + tl._RULER_H + 24 px = QColor(img.pixel(x, y)) assert px.green() > 200 assert px.green() > px.red() + 80 def test_locked_marker_click_anchors_start_and_shows_end_playhead(win, monkeypatch): win._file_path = "/x/video.mp4" win._timeline._duration = 60.0 win._spn_clip_dur.setValue(8.0) win._spn_clips.setValue(1) win._spn_spread.setValue(3.0) win._timeline.set_clip_span(win._clip_span, win._clip_dur, win._spn_spread.value()) win._btn_lock.setChecked(True) seeks = [] monkeypatch.setattr(win._mpv, "seek", lambda t: seeks.append(t)) monkeypatch.setattr(win._mpv, "get_duration", lambda: 60.0) monkeypatch.setattr( win._db, "get_by_output_path", lambda _path: {"clip_count": 3, "clip_duration": 6.0, "spread": 2.0}, ) win._on_marker_clicked(10.0, "/tmp/clip_001.mp4") assert abs(win._cursor - 10.0) < 0.01 assert abs(win._timeline._cursor - 10.0) < 0.01 assert abs(win._timeline._clip_span - 10.0) < 0.01 assert abs((win._timeline._play_pos or 0.0) - 20.0) < 0.01 assert seeks == [20.0] def test_timeline_audio_mode_follows_deck(win): win._control_deck.setCurrentWidget(win._tab_audio) assert win._timeline._audio_mode is True win._control_deck.setCurrentWidget(win._tab_export) assert win._timeline._audio_mode is False def test_timeline_audio_region_edit_syncs_cursor_length(win): win._file_path = "/x/v.mp4" win._cursor = 2.0 win._spn_audio_len.setValue(3.0) win._timeline.audio_region_changed.emit(5.0, 9.0) # user dragged the band to [5,9] assert win._cursor == 5.0 assert abs(win._spn_audio_len.value() - 4.0) < 1e-6 def test_audio_deck_tab_exists(win): # The old "Scan" deck tab is now "Audio". assert hasattr(win, "_tab_audio") assert win._tab_audio._deck_key == "audio" assert win._tab_audio in win._deck_panels assert not hasattr(win, "_tab_scan") labels = [win._control_deck.tabText(i) for i in range(win._control_deck.count())] assert "Audio" in labels and "Scan" not in labels def test_audio_edit_widgets_exist(win): from PyQt6.QtWidgets import QComboBox, QCheckBox, QDoubleSpinBox assert isinstance(win._cmb_audio_fmt, QComboBox) assert win._cmb_audio_fmt.count() >= 3 # wav/mp3/flac at least assert isinstance(win._spn_fade_in, QDoubleSpinBox) assert isinstance(win._spn_fade_out, QDoubleSpinBox) assert isinstance(win._chk_normalize, QCheckBox) assert isinstance(win._spn_gain, QDoubleSpinBox) assert win._cmb_audio_fmt.findData(".wav") >= 0 assert win._cmb_audio_fmt.findData(".mp3") >= 0 assert win._cmb_audio_fmt.findData(".flac") >= 0 def test_audio_toolbox_has_two_panes(win): from PyQt6.QtWidgets import QToolBox, QPushButton tb = win._tab_audio.findChild(QToolBox) assert tb is not None titles = [tb.itemText(i) for i in range(tb.count())] assert titles[0].startswith("Extract") assert any("Scan" in t or "Classify" in t for t in titles) # Extract controls now live under the Audio tab. assert win._btn_extract_audio in tb.findChildren(QPushButton) # Scan controls remain reachable. assert win._btn_scan in tb.findChildren(QPushButton) def test_merge_pane_present(win): from PyQt6.QtWidgets import QToolBox, QListWidget, QDoubleSpinBox tb = win._tab_audio.findChild(QToolBox) titles = [tb.itemText(i) for i in range(tb.count())] assert "Merge" in titles assert isinstance(win._merge_list, QListWidget) assert isinstance(win._spn_crossfade, QDoubleSpinBox) assert win._spn_crossfade.value() == 0.5 def test_extract_honors_format_and_edits(win, monkeypatch, tmp_path): import main as m import pytest win._file_path = "/x/video.mp4" win._cursor = 5.0 win._spn_audio_len.setValue(4.0) win._cmb_audio_fmt.setCurrentIndex(win._cmb_audio_fmt.findData(".mp3")) win._spn_fade_in.setValue(0.5) win._chk_normalize.setChecked(True) seen = {} class _Stop(Exception): pass def fake_cmd(inp, start, dur, out, filters=None): seen["out"] = out seen["filters"] = filters or [] raise _Stop monkeypatch.setattr(m, "build_audio_clip_command", fake_cmd) dialog = {} def fake_savedialog(parent, title, default_path, filt): dialog["default"] = default_path dialog["filter"] = filt return (str(tmp_path / "clip.mp3"), "") monkeypatch.setattr(m.QFileDialog, "getSaveFileName", staticmethod(fake_savedialog)) with pytest.raises(_Stop): win._on_extract_audio() # Default filename used the picked format's extension, and that format leads the filter. assert dialog["default"].endswith(".mp3") assert dialog["filter"].startswith("MP3") # Output path + edit filters flowed into the command builder. assert seen["out"].endswith(".mp3") assert any("afade=t=in" in f for f in seen["filters"]) assert "loudnorm" in seen["filters"] def test_extract_no_edits_passes_no_filters(win, monkeypatch, tmp_path): import main as m import pytest win._file_path = "/x/video.mp4" win._cursor = 5.0 win._spn_audio_len.setValue(4.0) # all edit controls at default win._spn_fade_in.setValue(0.0) win._spn_fade_out.setValue(0.0) win._chk_normalize.setChecked(False) win._spn_gain.setValue(0.0) seen = {} class _Stop(Exception): pass def fake_cmd(inp, start, dur, out, filters=None): seen["filters"] = filters raise _Stop monkeypatch.setattr(m, "build_audio_clip_command", fake_cmd) monkeypatch.setattr(m.QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (str(tmp_path / "c.wav"), ""))) with pytest.raises(_Stop): win._on_extract_audio() assert seen["filters"] is None def test_add_selection_to_library(win, tmp_path, monkeypatch): import main as m win._file_path = "/vids/Chloe.mp4" win._cursor = 7.0 win._spn_audio_len.setValue(3.0) win._settings.setValue("audio_extract_dir", str(tmp_path)) win._settings.setValue("audio_library_dir", "") # force derive seen = {} def fake_cmd(inp, start, dur, out, filters=None): seen["out"] = out; seen["start"] = start; seen["dur"] = dur; return ["true"] monkeypatch.setattr(m, "build_audio_clip_command", fake_cmd) monkeypatch.setattr(m.subprocess, "run", lambda *a, **k: type("P", (), {"returncode": 0})()) monkeypatch.setattr(m.os.path, "exists", lambda p: True) monkeypatch.setattr(m, "probe_duration", lambda p: 3.0) lib = win._scan_panel._library lib._key = "audio_library_addsel_test" win._settings.setValue(lib._key, []) lib._list.clear() win._on_add_selection_to_library() # rendered the current area with the managed-folder auto-name assert seen["start"] == 7.0 and seen["dur"] == 3.0 assert os.path.join("audio_library", "Chloe_7.00-10.00s.wav") in seen["out"] # and it was added to the library assert seen["out"] in lib.clips() def test_add_selection_to_library_no_file_safe(win): win._file_path = "" win._on_add_selection_to_library() # must not raise def test_waveform_strip_present_and_safe(win): from PyQt6.QtWidgets import QPushButton assert win._wave is not None assert isinstance(win._btn_wave_refresh, QPushButton) # set_peaks stores + doesn't crash win._wave.set_peaks([0.1, 0.5, 0.9]) assert list(win._wave._peaks) == [0.1, 0.5, 0.9] win._wave.clear() assert win._wave._peaks == [] # refresh with no file loaded is a safe no-op win._file_path = "" win._on_wave_refresh() def test_waveform_view_and_selection(win): w = win._wave w.set_view(10.0, 4.0) w.set_selection(11.0, 12.5) assert w._view_start == 10.0 and w._view_dur == 4.0 assert w.selection() == (11.0, 12.5) # selection clamps into the view window and keeps start < end w.set_selection(9.0, 20.0) s, e = w.selection() assert s >= 10.0 and e <= 14.0 and s < e w.set_peaks([0.1, 0.9, 0.3]) # still paints without crashing w.set_playhead(11.5) w.clear() # resets display state assert w.selection() is None def test_waveform_click_sets_playhead_without_changing_selection(win): from PyQt6.QtCore import QEvent, QPointF, Qt from PyQt6.QtGui import QMouseEvent w = win._wave w.resize(400, 96) w.set_view(0.0, 10.0) w.set_selection(2.0, 4.0) got = [] w.playhead_changed.connect(lambda t: got.append(t)) x = w._t_to_px(7.0) press = QMouseEvent( QEvent.Type.MouseButtonPress, QPointF(x, 20), Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier) release = QMouseEvent( QEvent.Type.MouseButtonRelease, QPointF(x, 20), Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier) w.mousePressEvent(press) w.mouseReleaseEvent(release) assert w.selection() == (2.0, 4.0) assert got and abs(got[-1] - 7.0) < 0.05 assert abs((w.playhead() or 0.0) - 7.0) < 0.05 def test_waveform_ctrl_drag_moves_selection_without_moving_playhead(win): from PyQt6.QtCore import QEvent, QPointF, Qt from PyQt6.QtGui import QMouseEvent w = win._wave w.resize(400, 96) w.set_view(0.0, 10.0) w.set_selection(2.0, 4.0) w.set_playhead(7.0) got_sel = [] got_play = [] w.selection_changed.connect(lambda s, e: got_sel.append((s, e))) w.playhead_changed.connect(lambda t: got_play.append(t)) press = QMouseEvent( QEvent.Type.MouseButtonPress, QPointF(w._t_to_px(3.0), 20), Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.ControlModifier) move = QMouseEvent( QEvent.Type.MouseMove, QPointF(w._t_to_px(5.0), 20), Qt.MouseButton.NoButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.ControlModifier) release = QMouseEvent( QEvent.Type.MouseButtonRelease, QPointF(w._t_to_px(5.0), 20), Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton, Qt.KeyboardModifier.ControlModifier) w.mousePressEvent(press) w.mouseMoveEvent(move) w.mouseReleaseEvent(release) s, e = w.selection() assert abs(s - 4.0) < 0.05 assert abs(e - 6.0) < 0.05 assert got_sel and abs(got_sel[-1][0] - 4.0) < 0.05 assert got_play == [] assert abs((w.playhead() or 0.0) - 7.0) < 0.05 def test_waveform_drag_emits_selection(win): w = win._wave w.resize(400, 96) w.set_view(10.0, 4.0) w.set_selection(11.0, 13.0) got = [] w.selection_changed.connect(lambda s, e: got.append((s, e))) # new selection: press near t=10.5 (not on a handle), drag to t=13.5, release w._begin_drag_at_x(w._t_to_px(10.5)) w._drag_to_x(w._t_to_px(13.5)) w._end_drag() assert got, "selection_changed should fire on release" s, e = w.selection() assert 10.0 <= s < e <= 14.0 def test_waveform_zoom_emits_view(win): w = win._wave w.resize(400, 96) w.set_view(10.0, 4.0) seen = [] w.view_changed.connect(lambda s, d: seen.append((s, d))) w._zoom_at_x(200, 0.5) # zoom in about the middle assert seen and seen[-1][1] < 4.0 def test_waveform_selection_syncs_cursor_and_length(win): win._file_path = "/x/video.mp4" win._cursor = 10.0 win._spn_audio_len.setValue(3.0) win._wave.set_view(10.0, 6.0) win._wave.set_selection(11.0, 14.0) win._on_wave_selection_changed(11.0, 14.0) assert win._cursor == 11.0 assert abs(win._spn_audio_len.value() - 3.0) < 1e-6 def test_audition_button_present_and_safe(win): from PyQt6.QtWidgets import QPushButton assert isinstance(win._btn_audio_play, QPushButton) assert win._btn_audio_play.isCheckable() assert win._btn_audio_play.text() == "▶ Play" # No file loaded -> toggling on must be a safe no-op that snaps back to unchecked. win._file_path = "" win._btn_audio_play.setChecked(True) # fires _on_audio_audition(True) assert win._btn_audio_play.isChecked() is False # Stopping when nothing is playing must be safe. win._stop_audition() assert win._btn_audio_play.isChecked() is False assert win._btn_audio_play.text() == "▶ Play" assert win._audition_proc is None def test_audition_clears_waveform_playhead(win): win._wave.set_playhead(5.0) win._stop_audition() assert win._wave._playhead is None assert not win._audition_playhead_timer.isActive() def test_merge_list_add_remove_reorder(win, tmp_path): from PyQt6.QtCore import Qt a = tmp_path / "a.wav"; b = tmp_path / "b.wav" a.write_bytes(b""); b.write_bytes(b"") win._merge_add_paths([str(a), str(b)]) assert win._merge_list.count() == 2 assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole) == str(a) win._merge_list.setCurrentRow(1) win._merge_move(-1) # move b up assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole) == str(b) win._merge_list.setCurrentRow(0) win._merge_remove_selected() assert win._merge_list.count() == 1 def test_merge_add_selection_no_file_safe(win): win._file_path = "" win._merge_list.clear() win._on_merge_add_selection() # must not raise assert win._merge_list.count() == 0 def test_merge_move_bounds_safe(win, tmp_path): a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"") win._merge_list.clear() win._merge_add_paths([str(a), str(b)]) win._merge_list.setCurrentRow(0) win._merge_move(-1) # already top -> no-op assert win._merge_list.currentRow() == 0 win._merge_list.setCurrentRow(1) win._merge_move(1) # already bottom -> no-op assert win._merge_list.currentRow() == 1 win._merge_list.setCurrentRow(-1) win._merge_move(1) # no selection -> safe win._merge_remove_selected() # no selection -> safe assert win._merge_list.count() == 2 def test_merge_add_stores_duration_role(win, tmp_path): from PyQt6.QtCore import Qt a = tmp_path / "a.wav"; a.write_bytes(b"") win._merge_list.clear() win._merge_add_paths([str(a)]) # empty fixture -> ffprobe fails -> duration is None; M4 must tolerate None assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole + 1) is None def test_merge_save_builds_command(win, tmp_path, monkeypatch): import main as m, pytest from PyQt6.QtCore import Qt a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"") win._merge_list.clear() win._merge_add_paths([str(a), str(b)]) win._merge_list.item(0).setData(Qt.ItemDataRole.UserRole + 1, 5.0) # long enough win._merge_list.item(1).setData(Qt.ItemDataRole.UserRole + 1, 5.0) win._spn_crossfade.setValue(0.75) seen = {} class _Stop(Exception): pass def fake(clips, xf, out, curves=None): seen.update(clips=clips, xf=xf, out=out, curves=curves); raise _Stop monkeypatch.setattr(m, "build_crossfade_merge_command", fake) monkeypatch.setattr(m.QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (str(tmp_path / "m.wav"), ""))) with pytest.raises(_Stop): win._on_merge_save() assert seen["clips"] == [str(a), str(b)] assert seen["xf"] == [0.75] # 1 join for 2 clips assert seen["curves"] == ["tri"] assert seen["out"].endswith(".wav") def test_merge_curve_combo_present(win): from PyQt6.QtWidgets import QComboBox assert isinstance(win._cmb_curve, QComboBox) assert win._cmb_curve.currentData() == "tri" # default def test_merge_render_builds_per_join_lists(win, tmp_path, monkeypatch): import main as m, pytest from PyQt6.QtCore import Qt a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; c = tmp_path / "c.wav" for f in (a, b, c): f.write_bytes(b"") win._merge_list.clear() win._merge_add_paths([str(a), str(b), str(c)]) for i in range(3): win._merge_list.item(i).setData(Qt.ItemDataRole.UserRole + 1, 5.0) # long enough win._spn_crossfade.setValue(0.5) seen = {} class _Stop(Exception): pass def fake(clips, xf, out, curves=None): seen.update(clips=clips, xf=xf, out=out, curves=curves); raise _Stop monkeypatch.setattr(m, "build_crossfade_merge_command", fake) monkeypatch.setattr(m.QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (str(tmp_path / "m.wav"), ""))) with pytest.raises(_Stop): win._on_merge_save() assert seen["xf"] == [0.5, 0.5] # 2 joins for 3 clips, effective = global assert seen["curves"] == ["tri", "tri"] def test_merge_save_blocks_short_clip(win, tmp_path, monkeypatch): import main as m from PyQt6.QtCore import Qt a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"") win._merge_list.clear() win._merge_add_paths([str(a), str(b)]) win._merge_list.item(0).setData(Qt.ItemDataRole.UserRole + 1, 0.1) # shorter than crossfade win._merge_list.item(1).setData(Qt.ItemDataRole.UserRole + 1, 5.0) win._spn_crossfade.setValue(0.5) calls = {"n": 0} monkeypatch.setattr(m, "build_crossfade_merge_command", lambda *a, **k: calls.__setitem__("n", calls["n"] + 1) or []) monkeypatch.setattr(m.QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (_ for _ in ()).throw(AssertionError("dialog opened")))) win._on_merge_save() # guard must block before dialog/builder assert calls["n"] == 0 def test_merge_save_empty_is_noop(win): win._merge_list.clear() win._on_merge_save() # no raise, no dialog def test_merge_preview_blocks_short_clip(win, tmp_path, monkeypatch): import main as m from PyQt6.QtCore import Qt a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"") win._merge_list.clear() win._merge_add_paths([str(a), str(b)]) win._merge_list.item(0).setData(Qt.ItemDataRole.UserRole + 1, 0.1) win._merge_list.item(1).setData(Qt.ItemDataRole.UserRole + 1, 5.0) win._spn_crossfade.setValue(0.5) calls = {"n": 0} monkeypatch.setattr(m, "build_crossfade_merge_command", lambda *a, **k: calls.__setitem__("n", calls["n"] + 1) or []) win._on_merge_preview() assert calls["n"] == 0 def test_merge_preview_empty_is_noop(win): win._merge_list.clear() win._on_merge_preview() def test_merge_override_applies_and_labels(win, tmp_path): from PyQt6.QtCore import Qt a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"") win._merge_list.clear(); win._merge_add_paths([str(a), str(b)]) it0 = win._merge_list.item(0) win._apply_merge_override(it0, 0.8, "exp") assert it0.data(Qt.ItemDataRole.UserRole + 2) == 0.8 assert it0.data(Qt.ItemDataRole.UserRole + 3) == "exp" assert "0.80s exp" in it0.text() assert "⤲" not in win._merge_list.item(1).text() # last item shows no join info xfs, cvs = win._merge_join_values() assert xfs == [0.8] and cvs == ["exp"] def test_merge_double_click_last_is_noop(win, tmp_path): a = tmp_path / "a.wav"; a.write_bytes(b"") win._merge_list.clear(); win._merge_add_paths([str(a)]) win._on_merge_item_double_clicked(win._merge_list.item(0)) # only/last -> no dialog, no raise assert win._merge_list.count() == 1 def test_audio_editor_dialog_scaffold(win): import main as win_module from PyQt6.QtWidgets import QPushButton dlg = win_module.AudioEditorDialog("/nonexistent.wav", parent=win) # version stack starts with the initial file assert dlg._versions == ["/nonexistent.wav"] assert dlg._ver_idx == 0 assert dlg._current() == "/nonexistent.wav" # widgets present assert dlg._wave is not None for name in ("_btn_heal_cut", "_btn_delete", "_btn_silence", "_btn_reverse", "_btn_trim", "_btn_undo", "_btn_redo", "_btn_loop_join", "_btn_save_library", "_btn_save_as"): assert isinstance(getattr(dlg, name), QPushButton) # undo disabled at the base version, redo disabled with no forward history assert not dlg._btn_undo.isEnabled() assert not dlg._btn_redo.isEnabled() # a bad path decodes to an empty waveform without crashing dlg._reload() dlg.close() def test_editor_delete_builds_command(win, tmp_path, monkeypatch): import main as m, pytest src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._wave.set_view(0.0, 5.0); dlg._wave.set_selection(1.0, 3.0) seen = {} class _Stop(Exception): pass def fake(inp, s, e, out): seen.update(inp=inp, s=s, e=e); raise _Stop monkeypatch.setattr(m, "build_audio_delete_command", fake) with pytest.raises(_Stop): dlg._on_delete() assert seen["inp"] == str(src) and seen["s"] == 1.0 and seen["e"] == 3.0 def test_editor_heal_cut_builds_command(win, tmp_path, monkeypatch): import main as m, pytest src = tmp_path / "v0.wav" src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._clip_dur = 6.0 dlg._wave.set_view(0.0, 6.0) dlg._wave.set_selection(2.0, 3.0) seen = {} class _Stop(Exception): pass def fake(inp, s, e, out): seen.update(inp=inp, s=s, e=e, out=out) raise _Stop monkeypatch.setattr(m, "build_audio_heal_delete_command", fake) with pytest.raises(_Stop): dlg._on_heal_cut() assert seen["inp"] == str(src) assert seen["s"] == 2.0 and seen["e"] == 3.0 assert seen["out"].endswith(".wav") def test_editor_op_no_region_is_safe(win, tmp_path, monkeypatch): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._wave.set_view(0.0, 5.0); dlg._wave.set_selection(2.0, 2.0) # zero-width n = {"c": 0} monkeypatch.setattr(m, "build_audio_delete_command", lambda *a: n.__setitem__("c", n["c"] + 1) or []) dlg._on_delete() assert n["c"] == 0 # guarded, builder never called def test_editor_delete_whole_clip_blocked(win, tmp_path, monkeypatch): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._clip_dur = 5.0 dlg._wave.set_view(0.0, 5.0); dlg._wave.set_selection(0.0, 5.0) # whole clip n = {"c": 0} monkeypatch.setattr(m, "build_audio_delete_command", lambda *a: n.__setitem__("c", n["c"] + 1) or []) dlg._on_delete() assert n["c"] == 0 # can't delete the whole clip def test_editor_op_uses_clip_duration_not_view(win, tmp_path, monkeypatch): import main as m, pytest src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._clip_dur = 5.0 dlg._wave.set_view(0.5, 4.0) # zoomed sub-window (view ends at 4.5) dlg._wave.set_selection(1.0, 4.5) seen = {} class _Stop(Exception): pass def fake(inp, s, e, out): seen.update(s=s, e=e); raise _Stop monkeypatch.setattr(m, "build_audio_silence_command", fake) with pytest.raises(_Stop): dlg._on_silence() assert seen["e"] == 4.5 # clip duration, not the 4.0 view span def test_editor_undo_redo(win, tmp_path, monkeypatch): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) monkeypatch.setattr(dlg, "_reload", lambda: None) dlg._versions = [str(src), "/t/v1.wav", "/t/v2.wav"]; dlg._ver_idx = 2 dlg._on_undo(); assert dlg._ver_idx == 1 dlg._on_undo(); assert dlg._ver_idx == 0 dlg._on_undo(); assert dlg._ver_idx == 0 # clamped at base dlg._on_redo(); assert dlg._ver_idx == 1 dlg._on_redo(); assert dlg._ver_idx == 2 dlg._on_redo(); assert dlg._ver_idx == 2 # clamped at top def test_editor_play_stop_safe(win, tmp_path): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._stop_play() # no proc -> safe no-op assert dlg._btn_play.text() == "▶ Play" def test_editor_stop_play_unchecks_button(win, tmp_path): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._btn_play.setChecked(True) dlg._stop_play() assert not dlg._btn_play.isChecked() def test_editor_reload_starts_with_playhead_and_no_selection(win, tmp_path, monkeypatch): import main as m import core.waveform as wf import numpy as np src = tmp_path / "v0.wav"; src.write_bytes(b"") monkeypatch.setattr(m, "probe_duration", lambda _p: 4.0) monkeypatch.setattr( wf, "load_region_samples", lambda path, start, dur: np.ones(32000, dtype="float32")) dlg = m.AudioEditorDialog(str(src), parent=win) assert dlg._wave.selection() is None assert dlg._wave.playhead() == 0.0 def test_editor_play_button_starts_from_playhead_outside_selection(win, tmp_path, monkeypatch): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._clip_dur = 5.0 dlg._wave.set_view(0.0, 5.0) dlg._wave.set_selection(1.25, 2.75) dlg._wave.set_playhead(4.0) seen = {} monkeypatch.setattr( dlg, "_play_current", lambda start=None, end=None, loop=False: seen.update(start=start, end=end, loop=loop)) dlg._on_play(True) assert seen == {"start": 4.0, "end": 5.0, "loop": False} def test_editor_play_button_loops_selection_when_playhead_inside(win, tmp_path, monkeypatch): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._clip_dur = 5.0 dlg._wave.set_view(0.0, 5.0) dlg._wave.set_selection(1.25, 2.75) dlg._wave.set_playhead(2.0) seen = {} monkeypatch.setattr( dlg, "_play_current", lambda start=None, end=None, loop=False: seen.update(start=start, end=end, loop=loop)) dlg._on_play(True) assert seen == {"start": 1.25, "end": 2.75, "loop": True} def test_editor_playhead_change_keeps_playback_running(win, tmp_path, monkeypatch): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._clip_dur = 5.0 dlg._wave.set_view(0.0, 5.0) dlg._wave.set_selection(1.25, 2.75) dlg._wave.set_playhead(4.0) dlg._play_proc = object() dlg._btn_play.setChecked(True) seen = {} stopped = {"n": 0} monkeypatch.setattr( dlg, "_play_current", lambda start=None, end=None, loop=False: seen.update(start=start, end=end, loop=loop)) monkeypatch.setattr(dlg, "_stop_play", lambda: stopped.__setitem__("n", stopped["n"] + 1)) dlg._on_wave_playhead_changed(4.0) assert seen == {"start": 4.0, "end": 5.0, "loop": False} assert stopped["n"] == 0 assert dlg._btn_play.isChecked() def test_editor_playhead_change_keeps_looping_when_inside_selection(win, tmp_path, monkeypatch): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._clip_dur = 5.0 dlg._wave.set_view(0.0, 5.0) dlg._wave.set_selection(1.25, 2.75) dlg._wave.set_playhead(2.0) dlg._play_proc = object() dlg._btn_play.setChecked(True) seen = {} stopped = {"n": 0} monkeypatch.setattr( dlg, "_play_current", lambda start=None, end=None, loop=False: seen.update(start=start, end=end, loop=loop)) monkeypatch.setattr(dlg, "_stop_play", lambda: stopped.__setitem__("n", stopped["n"] + 1)) dlg._on_wave_playhead_changed(2.0) assert seen == {"start": 1.25, "end": 2.75, "loop": True} assert stopped["n"] == 0 def test_editor_selection_change_moves_playhead_to_area_start(win, tmp_path): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._clip_dur = 6.0 dlg._wave.set_view(0.0, 6.0) dlg._wave.set_playhead(5.0) dlg._on_wave_selection_changed(1.5, 3.5) assert dlg._wave.playhead() == 1.5 def test_editor_selection_change_updates_active_loop_bounds(win, tmp_path, monkeypatch): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._clip_dur = 6.0 dlg._wave.set_view(0.0, 6.0) dlg._wave.set_selection(1.0, 3.0) dlg._wave.set_playhead(2.0) dlg._play_proc = object() dlg._play_loop = True seen = {} monkeypatch.setattr( dlg, "_play_current", lambda start=None, end=None, loop=False: seen.update(start=start, end=end, loop=loop)) dlg._on_wave_selection_changed(1.0, 4.0) assert seen == {"start": 1.0, "end": 4.0, "loop": True} assert dlg._wave.playhead() == 1.0 def test_editor_has_local_speed_controls(win, tmp_path): import main as m from PyQt6.QtWidgets import QPushButton src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) assert isinstance(dlg._btn_speed2, QPushButton) assert isinstance(dlg._btn_speed4, QPushButton) dlg._btn_speed2.setChecked(True) dlg._set_playback_speed(2.0) assert dlg._play_speed == 2.0 dlg._btn_speed4.setChecked(True) dlg._set_playback_speed(4.0) assert dlg._play_speed == 4.0 assert not dlg._btn_speed2.isChecked() def test_editor_has_local_playback_shortcuts(win, tmp_path): import main as m from PyQt6.QtGui import QShortcut src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) keys = {sc.key().toString() for sc in dlg.findChildren(QShortcut)} assert {"Space", "P", "K"} <= keys def test_editor_play_args_include_selection_and_speed(win, tmp_path): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._play_speed = 2.0 args = dlg._play_args(start=1.0, end=3.5, loop=True) assert args[:3] == ["-autoexit", "-nodisp", "-loglevel"] assert "-loop" in args and args[args.index("-loop") + 1] == "0" assert "-ss" in args and args[args.index("-ss") + 1] == "1.0" assert "-t" in args and args[args.index("-t") + 1] == "2.5" assert "-af" in args and args[args.index("-af") + 1] == "atempo=2.0" assert args[-1] == str(src) def test_editor_playhead_tracks_editor_range_and_speed(win, tmp_path): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) class FakeElapsed: def elapsed(self): return 1500 dlg._play_start = 2.0 dlg._play_end = 6.0 dlg._play_speed = 2.0 dlg._play_elapsed = FakeElapsed() dlg._tick_playhead() assert dlg._wave._playhead == 5.0 def test_editor_reload_uses_dense_waveform_buckets(win, tmp_path, monkeypatch): import main as m import core.waveform as wf import numpy as np src = tmp_path / "v0.wav"; src.write_bytes(b"") calls = {} monkeypatch.setattr(m, "probe_duration", lambda _p: 4.0) monkeypatch.setattr( wf, "load_region_samples", lambda path, start, dur: np.ones(32000, dtype="float32")) def fake_peaks(samples, buckets=128): calls["buckets"] = buckets return [0.1] * buckets monkeypatch.setattr(wf, "peaks", fake_peaks) dlg = m.AudioEditorDialog(str(src), parent=win) assert calls["buckets"] >= 1000 assert len(dlg._wave._peaks) == calls["buckets"] def test_editor_loop_join_plays_preview_range(win, tmp_path, monkeypatch): import main as m src = tmp_path / "v0.wav" src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._join_preview = (1.0, 3.0) seen = {} monkeypatch.setattr( dlg, "_play_current", lambda start=None, end=None: seen.update(start=start, end=end)) dlg._on_loop_join() assert seen == {"start": 1.0, "end": 3.0} def test_editor_heal_cut_marks_healed_seam(win, tmp_path): import main as m src = tmp_path / "v0.wav" src.write_bytes(b"") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._prepare_join_preview(2.0) assert dlg._join_preview == (1.0, 3.0) assert dlg._wave._markers == [2.0] assert "2.00s" in dlg._status.text() def test_editor_save_to_library_adds_current_version(win, tmp_path, monkeypatch): import main as m src = tmp_path / "v0.wav" src.write_bytes(b"") out_dir = tmp_path / "library" win._settings.setValue("audio_library_dir", str(out_dir)) dlg = m.AudioEditorDialog(str(src), parent=win) dlg._clip_dur = 2.0 saved = [] monkeypatch.setattr( m, "build_audio_clip_command", lambda inp, start, dur, out: ["ffmpeg", out]) def fake_run(cmd, capture_output=True, timeout=300): target = cmd[-1] os.makedirs(os.path.dirname(target), exist_ok=True) with open(target, "wb") as f: f.write(b"x") class Proc: returncode = 0 return Proc() monkeypatch.setattr(m.subprocess, "run", fake_run) win._scan_panel._library.add_clip = lambda p: saved.append(p) dlg._on_save_to_library() assert saved assert saved[0].startswith(str(out_dir)) def test_editor_close_cleans_temps(win, tmp_path): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") t1 = tmp_path / "t1.wav"; t1.write_bytes(b"x") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._temps.add(str(t1)) dlg.close() assert not t1.exists() assert os.path.exists(str(src)) # _versions[0] (entry temp) is NOT swept by the editor def test_editor_reject_cleans_temps(win, tmp_path): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"") t1 = tmp_path / "t1.wav"; t1.write_bytes(b"x") dlg = m.AudioEditorDialog(str(src), parent=win) dlg._temps.add(str(t1)) dlg.reject() # Escape path assert not t1.exists() # op temp swept assert os.path.exists(str(src)) # entry temp (_versions[0]) survives def test_audio_library_add_dedup_and_persist(win, tmp_path): import main as m a = tmp_path / "a.wav"; a.write_bytes(b"") b = tmp_path / "b.wav"; b.write_bytes(b"") win._settings.setValue("audio_library_test", []) lib = m.AudioLibraryTab(win._settings, key="audio_library_test") assert lib.clips() == [] lib.add_clip(str(a)); lib.add_clip(str(b)); lib.add_clip(str(a)) # dup ignored assert lib.clips() == [str(a), str(b)] # order preserved, deduped assert lib._list.count() == 2 # a fresh instance restores from the same key lib2 = m.AudioLibraryTab(win._settings, key="audio_library_test") assert lib2.clips() == [str(a), str(b)] def test_audio_library_drops_missing_on_load(win, tmp_path): import main as m good = tmp_path / "g.wav"; good.write_bytes(b"") win._settings.setValue("audio_library_test2", [str(good), "/no/such/file.wav"]) lib = m.AudioLibraryTab(win._settings, key="audio_library_test2") assert lib.clips() == [str(good)] # missing path dropped on load def test_audio_library_remove(win, tmp_path): import main as m a = tmp_path / "a.wav"; a.write_bytes(b""); b = tmp_path / "b.wav"; b.write_bytes(b"") win._settings.setValue("audio_library_test3", []) lib = m.AudioLibraryTab(win._settings, key="audio_library_test3") lib.add_clip(str(a)); lib.add_clip(str(b)) lib._list.setCurrentRow(0) lib._on_remove() assert lib.clips() == [str(b)] def test_audio_library_edit_emits(win, tmp_path): import main as m a = tmp_path / "a.wav"; a.write_bytes(b"") win._settings.setValue("audio_library_test4", []) lib = m.AudioLibraryTab(win._settings, key="audio_library_test4") lib.add_clip(str(a)); lib._list.setCurrentRow(0) got = [] lib.edit_requested.connect(lambda p: got.append(p)) lib._on_edit() assert got == [str(a)] def test_scan_panel_library_tab_persists(win): import main as m from PyQt6.QtWidgets import QTableWidget sp = win._scan_panel assert isinstance(sp._library, m.AudioLibraryTab) titles = [sp._tabs.tabText(i) for i in range(sp._tabs.count())] assert "Library" in titles # add a fake model tab, then clear model tabs -> library survives sp._tabs.addTab(QTableWidget(), "EAT_LARGE (3)") sp._clear_model_tabs() titles2 = [sp._tabs.tabText(i) for i in range(sp._tabs.count())] assert titles2 == ["Library"] # only the library remains assert sp._library is sp._tabs.widget(0) def test_scan_reload_selects_model_tab_not_library(win): # Reloading a scanned file must leave a MODEL tab current (index 1), not the # Library at index 0 — otherwise _current_table() is None and the timeline # shows no scan regions until the user manually clicks a model tab. sp = win._scan_panel sp._filename = "clip.mp4" sp._profile = "prof" results = {"EAT_LARGE": [(1, 0.0, 1.0, 0.9, False, 0.0, 1.0)]} sp._on_scan_bundle_loaded("clip.mp4", "prof", set(), [], results) assert sp._library is sp._tabs.widget(0) # Library stays at index 0 assert sp._tabs.currentIndex() == 1 # a model tab is selected assert sp._current_table() is not None # regions resolve immediately assert sp.current_model_name() == "EAT_LARGE" def test_open_editor_adds_saved_clip_to_library(win, tmp_path, monkeypatch): import main as m saved = tmp_path / "edited.wav"; saved.write_bytes(b"") class FakeDlg: def __init__(self, path, parent=None): self._last_saved = str(saved) def exec(self): return 0 def deleteLater(self): pass monkeypatch.setattr(m, "AudioEditorDialog", FakeDlg) lib = win._scan_panel._library lib._key = "audio_library_l3test" # throwaway, avoids polluting real settings win._settings.setValue("audio_library_l3test", []) lib._list.clear() win._open_audio_editor("/x/in.wav") assert str(saved) in lib.clips() def test_library_edit_signal_opens_editor(win, tmp_path, monkeypatch): import main as m opened = [] monkeypatch.setattr(win, "_open_audio_editor", lambda p: opened.append(p)) win._scan_panel._library.edit_requested.emit("/some/clip.wav") assert opened == ["/some/clip.wav"]