640 lines
25 KiB
Python
640 lines
25 KiB
Python
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_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_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_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
|