Files
UnheardCity/tests/test_app.py

335 lines
12 KiB
Python

"""GUI smoke tests: the editor driven headless (offscreen Qt, synthetic game, synchronous jobs)."""
import hashlib
import os
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 test_browse_search_filter(win):
w, root, _ = win
assert w.model.rowCount() == len(w.ws.catalog.sounds)
type_search(w, "radio scanner 2")
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_replace_apply_revert_byte_identical(win, tmp_path):
w, root, originals = win
before = files(root)
type_search(w, "CHR_RadioScanner_2")
assert w.select_name("CHR_RadioScanner_2")
ed = w.editor
assert ed.sound.name == "CHR_RadioScanner_2" and 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
assert w.select_name("CHR_SynthFoley_1")
ed = w.editor
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.editor.sound.name == "CHR_RadioScanner_2"
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)
w.select_name("CHR_RadioScanner_2")
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_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
w.select_name("CHR_RadioScanner_1")
ed = w.editor
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
w.select_name("CHR_RadioScanner_2")
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
w.select_name("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_spinners_while_loading(win, tmp_path):
w, root, _ = win
held = w.runner = HeldRunner(w.runner)
w.select_name("CHR_RadioScanner_1")
assert w.editor.wf_orig.busy and w.editor.wf_orig._timer.isActive()
held.release()
assert not w.editor.wf_orig.busy and not w.editor.wf_orig._timer.isActive()
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_editor_panel_fits_its_contents(win):
w, root, _ = win
w.resize(1400, 900)
w.show()
QTest.qWait(100)
need = w.editor.sizeHint().height()
assert w.scroll.viewport().height() >= need - 2, (w.scroll.viewport().height(), need)
assert w.vsplit.sizes()[0] >= 160