ad9e564991
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
193 lines
7.2 KiB
Python
193 lines
7.2 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_scan_tab(win):
|
|
from PyQt6.QtWidgets import QPushButton
|
|
assert win._btn_scan in win._tab_scan.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_scan._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()
|