"""GUI smoke tests: the editor driven headless (offscreen Qt, synthetic game, synchronous jobs).""" import hashlib import os import numpy as np import pytest pytest.importorskip("PySide6") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ["ACKIT_NO_AUDIO"] = "1" from PySide6.QtTest import QTest # noqa: E402 from PySide6.QtWidgets import QApplication # noqa: E402 from ackit import audio, workspace as W # noqa: E402 from ackit.inject import inject # noqa: E402 import synth # noqa: E402 pytestmark = pytest.mark.gui @pytest.fixture(scope="module") def qapp(): return QApplication.instance() or QApplication([]) def files(root): out = {} for d, _, fs in os.walk(os.path.join(root, "BmGame")): for f in fs: p = os.path.join(d, f) out[p] = hashlib.sha1(open(p, "rb").read()).hexdigest() return out @pytest.fixture def win(qapp, synth_game, tmp_path): from app.mainwindow import MainWindow from app.workers import Runner root, originals = synth_game w = MainWindow(settings={"full_backup": True, "language": "INT"}, runner=Runner(sync=True), game_dir=root) w.confirm = lambda *a: True w.inform = lambda *a: None w.editor.confirm = lambda *a: True w.editor.inform = lambda title, text: (_ for _ in ()).throw(AssertionError("%s: %s" % (title, text))) QTest.qWait(50) assert w.ws is not None, w.banner_text.text() yield w, root, originals w.close() def type_search(w, text): w.search.setText(text) w._filter_text() def edit(w, name): """Select a sound in the Sounds tab and open it in the Edit tab (what Enter / the Edit button do).""" assert w.select_name(name) w.edit_selected() assert w.tabs.currentWidget() is w.edit and w.editor.sound.name == name return w.editor def test_browse_search_filter(win): w, root, _ = win assert w.model.rowCount() == len(w.ws.catalog.sounds) type_search(w, "synth radio 02") assert w.proxy.rowCount() == 1 type_search(w, "") w.cmb_group.setCurrentText("Music") assert w.proxy.rowCount() == 3 and all(w.proxy.sound_at(w.proxy.index(r, 0)).group == "Music" for r in range(w.proxy.rowCount())) w.clear_filters() assert w.proxy.rowCount() == w.model.rowCount() def test_sort_order_survives_filter_changes(win): """Rows that a filter shows again go to their sorted place (not appended at the end).""" from PySide6.QtCore import Qt from app.models import C_NAME, SORT_ROLE w, _, _ = win def keys(): return [w.proxy.data(w.proxy.index(r, C_NAME), SORT_ROLE) for r in range(w.proxy.rowCount())] w.table.sortByColumn(C_NAME, Qt.SortOrder.AscendingOrder) assert keys() == sorted(keys()) w.cmb_group.setCurrentText("Music") w.clear_filters() type_search(w, "radio") type_search(w, "") assert w.proxy.rowCount() == w.model.rowCount() and keys() == sorted(keys()) w.table.sortByColumn(C_NAME, Qt.SortOrder.DescendingOrder) w.cmb_group.setCurrentText("Speech") w.clear_filters() assert keys() == sorted(keys(), reverse=True) def test_replace_apply_revert_byte_identical(win, tmp_path): w, root, originals = win before = files(root) type_search(w, "Speech_Synth_Radio_02") ed = edit(w, "Speech_Synth_Radio_02") assert ed.orig is not None wav = str(tmp_path / "new.wav") x, sr = synth.tone(1.0, 44100, 990) audio.save_wav(wav, x, sr) assert ed.load_file(wav) ed.cb_match.setChecked(True) ed._update_new() assert ed.processed() is not None ed.apply() s = ed.sound assert w.ws.state(s) == W.MODIFIED and s.key in w.model.modified assert files(root) != before w.cb_mod.setChecked(True) assert w.proxy.rowCount() == 1 ed.revert() assert files(root) == before assert w.ws.state(s) == W.ORIGINAL and not w.model.modified # undo of the original is impossible to lose: the store is empty again assert not w.ws.store.entries def test_bank_sound_apply_revert_in_editor(win, tmp_path): w, root, originals = win before = files(root) w.cmb_arch.setCurrentText("BANK") assert w.proxy.rowCount() == 7 # 6 neutral + the INT copy of a localized line ed = edit(w, "CHR_SynthFoley_1") assert "bank CHR_SynthFoley (2 copies)" in ed.meta.text() wav = str(tmp_path / "foley.wav") audio.save_wav(wav, *synth.tone(0.2, 44100, 1300)) assert ed.load_file(wav) ed._update_new() ed.apply() assert w.ws.state(ed.sound) == W.MODIFIED and files(root) != before ed.revert() assert files(root) == before and not w.ws.store.entries def test_subtitles_tab_edit_and_link(win): w, root, _ = win before = files(root) w.tabs.setCurrentWidget(w.subs) p = w.subs assert p.lang == "INT" and p.model.rowCount() == 3 p.search.setText("scanner") p._filter() assert p.proxy.rowCount() == 1 p.table.selectRow(0) assert p.edit.text() == "Scanner line two." and "/ 17 characters" in p.lbl_info.text() p.edit.setText("x" * 30) assert not p.b_apply.isEnabled() # longer than the room p.edit.setText("Scan two.") p.apply_current() key = p.current.key assert w.ws.texts.state(key) == "modified" and p.model.modified[key] == "Scan two." p.go_to_sound.emit(p.current.sound) # "Go to the sound" assert w.tabs.currentIndex() == 0 and w.preview.sound.name == "Speech_Synth_Radio_02" assert "Scan two." in w.preview.sub.text() # the browser shows it, read-only w.edit_selected() box = w.editor.subtitle assert [e.text() for e in box.edits] == ["Scan two.", "Second part."] box.edits[1].setText("Part two.") box.apply() assert w.ws.texts.current(key.replace(":0", ":1")) == "Part two." box.open_in_list.emit(box.keys[1]) # back to the Subtitles tab assert w.tabs.currentWidget() is p and p.current.key == box.keys[1] box.revert() assert files(root) == before and not w.ws.texts.records() def test_one_language_for_everything(win): w, root, _ = win assert w.language == "INT" and w.cmb_lang.currentText() == "INT" and not w.lbl_voices.text() assert {"INT", "ITA", "RUS"} <= {w.cmb_lang.itemText(i) for i in range(w.cmb_lang.count())} w.load_game(root, "RUS") # no Russian voices: INT speech, RUS texts assert w.language == "RUS" and "voices: INT" in w.lbl_voices.text() assert "INT" in w.ws.catalog.archives w.tabs.setCurrentWidget(w.subs) assert w.subs.lang == "RUS" and "RUS" in w.subs.lbl_lang.text() w.tabs.setCurrentIndex(0) edit(w, "Speech_Synth_Radio_02") assert [e.text() for e in w.editor.subtitle.edits] == ["Строка два"] w.tabs.setCurrentWidget(w.menu) assert w.menu.lang is None and w.menu.model.rowCount() == 0 # no RUS menu files in the synthetic game def test_subtitles_find_replace(win): w, root, _ = win before = files(root) w.tabs.setCurrentWidget(w.subs) p = w.subs assert p.find_replace("line", "row") == 1 assert w.ws.texts.current("SUB:INT:%s:0" % synth.SPEECH_EVENT) == "Scanner row two." p.revert_all() assert files(root) == before def test_menu_text_tab(win): w, root, _ = win before = files(root) w.tabs.setCurrentWidget(w.menu) p = w.menu assert p.model.rowCount() == 4 p.filters["File"].setCurrentText("FRONTEND.int") p.search.setText("press") p._filter() assert p.proxy.rowCount() == 1 p.table.selectRow(0) p.edit.setText("Press Enter to start") p.apply_current() assert w.ws.texts.current("MENU:INT:FRONTEND.int:Menu:Start") == "Press Enter to start" p.cb_mod.setChecked(True) assert p.proxy.rowCount() == 1 p.revert_all() assert files(root) == before def test_mod_with_texts(win, tmp_path): w, root, _ = win tx = w.ws.texts tx.apply({"SUB:INT:%s:0" % synth.SPEECH_EVENT: "Mod line.", "MENU:INT:FRONTEND.int:Menu:Quit": "Leave"}) pkg = str(tmp_path / "texts.acmod") w.export_mod_dialog(path=pkg, keys=tx.modified(), title="texts") tx.revert_all() w.import_mod_dialog(path=pkg, confirm=False) assert tx.current("MENU:INT:FRONTEND.int:Menu:Quit") == "Leave" assert tx.current("SUB:INT:%s:0" % synth.SPEECH_EVENT) == "Mod line." tx.revert_all() def test_revert_all_reverts_sounds_and_texts(win): w, root, _ = win before = files(root) tx = w.ws.texts tx.apply({"SUB:INT:%s:0" % synth.SPEECH_EVENT: "Undo me.", "MENU:INT:FRONTEND.int:Menu:Quit": "Leave"}) s = w.ws.sound("CHR_RadioScanner_1") w.ws.apply([(s, synth.wem(0.5, 1200), {})]) w.revert_all() assert files(root) == before and not w.ws.store.entries and not tx.records() def test_mod_import_asks_before_overwriting(win, tmp_path): """Files changed outside the editor are overwritten by a mod only when the user confirms.""" w, root, originals = win s = w.ws.sound("CHR_RadioScanner_1") w.ws.apply([(s, synth.wem(0.5, 1200), {})]) pkg = str(tmp_path / "m.acmod") w.export_mod_dialog(path=pkg, keys=[s.key], title="m") other = synth.wem(0.4, 150) inject(w.ws.wad_path(s), {s.target: other}, {s.target: w.ws.limit(s)}) # another tool writes the slot asked = [] w.confirm = lambda *a: asked.append(a) or False w.import_mod_dialog(path=pkg, confirm=False) assert asked and w.ws.current_bytes(s) == other # declined: left alone w.confirm = lambda *a: True w.import_mod_dialog(path=pkg, confirm=False) assert w.ws.state(s) == W.MODIFIED w.revert_all() def test_store_cannot_change_while_changes_are_applied(win, tmp_path, monkeypatch): from app import mainwindow w, root, _ = win s = w.ws.sound("CHR_RadioScanner_1") w.ws.apply([(s, synth.wem(0.5, 1200), {})]) store = w.ws.store.root class Dlg: def __init__(self, settings, parent): pass def exec(self): return mainwindow.QDialog.DialogCode.Accepted def values(self): return dict(w.settings, store_root=str(tmp_path / "elsewhere")) monkeypatch.setattr(mainwindow, "SettingsDialog", Dlg) told = [] w.inform = lambda *a: told.append(a) w.settings_dialog() assert told and w.settings.get("store_root") != str(tmp_path / "elsewhere") and w.ws.store.root == store w.revert_all() def test_text_undone_outside_shows_banner(win): w, root, _ = win tx = w.ws.texts k = "MENU:INT:FRONTEND.int:Menu:Start" tx.apply({k: "Go"}) path = tx.files(k)[0] open(path, "wb").write(open(w.ws.store.full_copy(w.ws.game.rel(path)), "rb").read()) # like 'verify files' w.check_problems() assert not w.banner.isHidden() and "text changes" in w.banner_text.text() w.reapply_lost() assert tx.current(k) == "Go" and tx.state(k) == "modified" tx.revert_all() def test_record_simulated_input(win): w, root, _ = win ed = edit(w, "CHR_RadioScanner_1") x, sr = synth.tone(0.5, 48000, 500) ed.recorder.start_simulated(x, sr, realtime=True) assert ed.recorder.is_recording() QTest.qWait(800) assert ed.meter.value() > 0 ed.toggle_record() # stop -> becomes the replacement source assert ed.src is not None and abs(len(ed.src[0]) - len(x)) <= sr // 50 ed.trim_silence() ed.apply() assert w.ws.state(ed.sound) == W.MODIFIED w.revert_all() assert not w.ws.modified_keys() def test_notes_tags_favorites(win): w, root, _ = win w.select_name("CHR_RadioScanner_3") s = w.selected_sounds()[0] w.edit_notes([s], note="needs a new line", tags=["todo", "radio"]) assert w.userdata.tags(s.key) == ["radio", "todo"] w.cmb_tag.setCurrentText("todo") assert w.proxy.rowCount() == 1 w.cmb_tag.setCurrentIndex(0) w._toggle_fav([s]) w.cb_fav.setChecked(True) assert w.proxy.rowCount() == 1 type_search(w, "needs a new") assert w.proxy.rowCount() == 1 def test_mod_export_import(win, tmp_path): w, root, _ = win edit(w, "Speech_Synth_Radio_02") x, sr = synth.tone(1.0, 24000, 1200) w.editor.set_source(x, sr, "tone") w.editor.apply() key = w.editor.sound.key pkg = str(tmp_path / "pkg.acmod") w.export_mod_dialog(path=pkg, keys=[key], title="test") assert os.path.exists(pkg) w.revert_all() assert not w.ws.modified_keys() w.import_mod_dialog(path=pkg, confirm=False) assert w.ws.modified_keys() == [key] w.revert_all() def test_changed_underneath_banner(win): w, root, originals = win edit(w, "CHR_RadioScanner_1") x, sr = synth.tone(0.5, 24000, 1500) w.editor.set_source(x, sr, "tone") w.editor.apply() s = w.editor.sound # Steam "verify files" puts the original back behind our back inject(w.ws.wad_path(s), {s.target: originals[s.target]}, {s.target: w.ws.limit(s)}) w.check_problems() assert not w.banner.isHidden() and "undone outside" in w.banner_text.text() w.reapply_lost() assert w.ws.state(s) == W.MODIFIED w.revert_all() class HeldRunner: """Keeps jobs pending until release(), to see the loading state.""" def __init__(self, real): self.real, self.held = real, [] def start(self, *args): self.held.append(args) def release(self): while self.held: self.real.start(*self.held.pop(0)) def test_selection_survives_sort_and_filter(win): from PySide6.QtCore import Qt from app.models import C_LEN w, _, _ = win assert w.select_name("Speech_Synth_Radio_02") s = w.preview.sound w.table.sortByColumn(C_LEN, Qt.SortOrder.DescendingOrder) type_search(w, "radio") cur = w.table.currentIndex() assert cur.isValid() and w.proxy.sound_at(cur) is s and [x.key for x in w.selected_sounds()] == [s.key] type_search(w, "music") # the row is filtered out: no current row assert not w.table.currentIndex().isValid() and not w.selected_sounds() def test_list_spinners_while_loading(win): """The sound list and the text lists show the loading overlay while they load (background jobs); a subtitle asked for meanwhile is selected when the rows arrive.""" from ackit import subtitles w, root, _ = win held = w.runner = HeldRunner(w.runner) w.load_game(root, "INT") assert w.table_busy.busy and w.table_busy.timer.isActive() held.release() assert not w.table_busy.busy and w.model.rowCount() == len(w.ws.catalog.sounds) w._text_loaded.clear() sub = w.ws.texts.subtitles("INT")[0] key = subtitles.key("INT", sub.speech, sub.line) w.show_subtitle(key) assert w.subs.busy.busy and w.subs.busy.timer.isActive() held.release() assert not w.subs.busy.busy and w.subs.current is not None and w.subs.current.key == key def test_spinners_while_loading(win, tmp_path): w, root, _ = win held = w.runner = HeldRunner(w.runner) w.select_name("CHR_RadioScanner_1") assert w.preview.wf_orig.busy and w.preview.wf_orig._timer.isActive() held.release() assert not w.preview.wf_orig.busy and not w.preview.wf_orig._timer.isActive() w.edit_selected() assert w.editor.wf_orig.busy held.release() assert not w.editor.wf_orig.busy wav = str(tmp_path / "a.wav") audio.save_wav(wav, *synth.tone(0.3, 22050, 700)) w.editor.load_file(wav) assert w.editor.wf_new.busy held.release() assert not w.editor.wf_new.busy and w.editor.src is not None w._run_change("Working…", lambda job: None, "done") assert not w.spinner.isHidden() held.release() assert w.spinner.isHidden() w.runner = held.real def test_background_toggle_and_filters_look_alike(win): w, root, _ = win w.sidebar.configure({"background_enabled": False}) assert w.sidebar.image is None # every filter selector is the same kind of plain drop-down assert not any(c.isEditable() for c in (w.cmb_group, w.cmb_cat, w.cmb_char, w.cmb_arch, w.cmb_tag)) def test_browser_only_browses_edit_tab_fits(win): """The Sounds tab has no editing tools (player + Edit); the Edit tab needs no scrolling at 1400 x 900 and 1280 x 800, even for a speech line with a two-part subtitle.""" from PySide6.QtWidgets import QPushButton w, root, _ = win texts = {b.text() for b in w.vsplit.findChildren(QPushButton)} assert {"Edit", "Add to edit list", "Play original", "Play installed", "Stop"} <= texts assert not texts & {"Record", "Apply to game", "Load file…", "Revert"} w.show() for size in ((1400, 900), (1280, 800)): w.resize(*size) edit(w, "Speech_Synth_Radio_02") # two subtitle parts: the tallest case QTest.qWait(100) assert w.editor.subtitle_box.isVisible() and len(w.editor.subtitle.edits) == 2 for area in (w.editor.parentWidget().parentWidget(), w.edit.rack.parentWidget().parentWidget()): assert not area.verticalScrollBar().isVisible() and not area.horizontalScrollBar().isVisible(), size edit(w, "CHR_SynthFoley_1") assert not w.editor.subtitle_box.isVisible() # no subtitle: no subtitle box def test_recorder_sample_formats(qapp): """Input devices may deliver 16/32-bit integer, 8-bit or float samples, and reads can split a frame.""" import numpy as np from app.audioio import Recorder r = Recorder() x = np.array([[0.5, -0.25], [0.125, -1.0]], np.float32) for sample, raw in (("