1409 lines
53 KiB
Python
1409 lines
53 KiB
Python
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_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"]
|