Skip to content

Commit 2560da7

Browse files
chrisdpurcellclaude
andcommitted
test: add three live test environments (qt-pilot fix, integration suite, offscreen smoke)
- scripts/qt_pilot_launcher.py: sets QT_IM_MODULE=none before QApplication to prevent SIGSEGV in QTest::keyClicks on headless Xvfb displays; use as the launch entry point for the qt-pilot MCP tool - tests/integration/test_live_scenarios.py: 12 end-to-end tests using real FileService + real TextProcessingService + real MainWindow; fills the gap between test_application.py (ViewModel only) and test_main_window.py (mocked services); verifies editor state, encoding label, title bar, status bar, cleaning, find, replace-all - scripts/live_test.py: standalone offscreen smoke test (QT_QPA_PLATFORM=offscreen) with 15 checks across load, save, clean, find, replace-all, and stub actions; exits 0 on pass, 1 on failure; no pytest or display dependency Total tests: 113 (was 101). Coverage: 99% unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d8d3abe commit 2560da7

3 files changed

Lines changed: 441 additions & 0 deletions

File tree

scripts/live_test.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
#!/usr/bin/env python
2+
"""Offscreen smoke test for TextTools.
3+
4+
Exercises the full stack (FileService → ViewModel → View widgets) using Qt's
5+
offscreen platform — no display or Xvfb required.
6+
7+
Run with:
8+
uv run python scripts/live_test.py
9+
10+
Exit code: 0 if all checks pass, 1 if any fail.
11+
12+
QT_QPA_PLATFORM=offscreen: renders widgets in memory; all Qt geometry, text
13+
cursor, and signal APIs work identically to a real display, but nothing is
14+
shown on screen. More stable than Xvfb for headless testing because there is
15+
no input method context to trip over.
16+
"""
17+
18+
import os
19+
import sys
20+
from pathlib import Path
21+
from tempfile import TemporaryDirectory
22+
23+
# Must be set before QApplication is created — do not move below imports.
24+
os.environ["QT_QPA_PLATFORM"] = "offscreen"
25+
os.environ["QT_IM_MODULE"] = "none"
26+
27+
# Add project root to sys.path so `src` is importable without install.
28+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
29+
sys.path.insert(0, str(PROJECT_ROOT))
30+
31+
from PySide6.QtWidgets import QApplication # noqa: E402
32+
33+
from src.main import create_application # noqa: E402
34+
35+
_pass = 0
36+
_fail = 0
37+
38+
39+
def check(label: str, condition: bool, detail: str = "") -> None:
40+
global _pass, _fail
41+
if condition:
42+
print(f" PASS {label}")
43+
_pass += 1
44+
else:
45+
note = f" — {detail}" if detail else ""
46+
print(f" FAIL {label}{note}")
47+
_fail += 1
48+
49+
50+
def section(title: str) -> None:
51+
print(f"\n{title}")
52+
print("─" * len(title))
53+
54+
55+
def main() -> None:
56+
app = QApplication(sys.argv)
57+
window = create_application()
58+
59+
with TemporaryDirectory() as tmp:
60+
tmp_path = Path(tmp)
61+
62+
# ----------------------------------------------------------------- load
63+
section("File Loading")
64+
65+
# Non-ASCII content (é, ñ) is required for the encoding check:
66+
# pure ASCII is correctly detected as "ascii" by chardet (ASCII ⊂ UTF-8).
67+
f = tmp_path / "hello.txt"
68+
f.write_text("café résumé — live test", encoding="utf-8")
69+
window._file_name_edit.setText(str(f))
70+
window._viewmodel.load_file(str(f))
71+
app.processEvents()
72+
73+
check(
74+
"Editor populated after load",
75+
window._plain_text_edit.toPlainText() == "café résumé — live test",
76+
)
77+
check(
78+
"Encoding label shows utf-8",
79+
window._encoding_label.text().lower() == "utf-8",
80+
)
81+
check(
82+
"Title bar shows filename",
83+
"hello.txt" in window.ui.windowTitle(),
84+
)
85+
check(
86+
"Title bar has no star (clean after load)",
87+
"*" not in window.ui.windowTitle(),
88+
)
89+
check(
90+
"Status bar mentions filename",
91+
"hello.txt" in window.ui.statusBar().currentMessage(),
92+
)
93+
94+
# ----------------------------------------------------------------- save
95+
section("Save")
96+
97+
out = tmp_path / "out.txt"
98+
window._file_name_edit.setText(str(out))
99+
window._plain_text_edit.setPlainText("saved content")
100+
window._on_save_clicked()
101+
app.processEvents()
102+
103+
check(
104+
"File written to disk",
105+
out.exists(),
106+
)
107+
check(
108+
"File content matches editor",
109+
out.exists() and out.read_text(encoding="utf-8") == "saved content",
110+
)
111+
check(
112+
"Title loses star after save",
113+
"*" not in window.ui.windowTitle(),
114+
)
115+
116+
# -------------------------------------------------------------- cleaning
117+
section("Text Cleaning (real TextProcessingService)")
118+
119+
dirty = tmp_path / "dirty.txt"
120+
dirty.write_text("hello \nworld \n\n", encoding="utf-8")
121+
window._file_name_edit.setText(str(dirty))
122+
window._viewmodel.load_file(str(dirty))
123+
app.processEvents()
124+
125+
window._trim_cb.setChecked(True)
126+
app.processEvents()
127+
128+
trimmed = window._plain_text_edit.toPlainText()
129+
check(
130+
"Trim whitespace strips trailing spaces and blank lines",
131+
trimmed == "hello\nworld",
132+
f"got: {trimmed!r}",
133+
)
134+
135+
window._trim_cb.setChecked(False)
136+
app.processEvents()
137+
138+
# Reload clean content for the next two checks
139+
spaces = tmp_path / "spaces.txt"
140+
spaces.write_text("a b c", encoding="utf-8")
141+
window._file_name_edit.setText(str(spaces))
142+
window._viewmodel.load_file(str(spaces))
143+
app.processEvents()
144+
145+
window._clean_cb.setChecked(True)
146+
app.processEvents()
147+
148+
collapsed = window._plain_text_edit.toPlainText()
149+
check(
150+
"Clean whitespace collapses runs of spaces",
151+
collapsed == "a b c",
152+
f"got: {collapsed!r}",
153+
)
154+
155+
window._clean_cb.setChecked(False)
156+
app.processEvents()
157+
158+
tabs_file = tmp_path / "tabs.txt"
159+
tabs_file.write_text("\tindented\n\t\tdouble", encoding="utf-8")
160+
window._file_name_edit.setText(str(tabs_file))
161+
window._viewmodel.load_file(str(tabs_file))
162+
app.processEvents()
163+
164+
window._remove_tabs_cb.setChecked(True)
165+
app.processEvents()
166+
167+
detabbed = window._plain_text_edit.toPlainText()
168+
check(
169+
"Remove tabs strips leading indent",
170+
detabbed == "indented\ndouble",
171+
f"got: {detabbed!r}",
172+
)
173+
174+
window._remove_tabs_cb.setChecked(False)
175+
app.processEvents()
176+
177+
# --------------------------------------------------------------- find
178+
section("Find")
179+
180+
search = tmp_path / "search.txt"
181+
search.write_text("the quick brown fox", encoding="utf-8")
182+
window._file_name_edit.setText(str(search))
183+
window._viewmodel.load_file(str(search))
184+
app.processEvents()
185+
186+
window._find_edit.setText("brown")
187+
window._on_find_clicked()
188+
selected = window._plain_text_edit.textCursor().selectedText()
189+
check(
190+
"Find selects matching text",
191+
selected == "brown",
192+
f"got: {selected!r}",
193+
)
194+
195+
# Wrap test: move cursor to end, search for earlier word
196+
cursor = window._plain_text_edit.textCursor()
197+
cursor.movePosition(cursor.MoveOperation.End)
198+
window._plain_text_edit.setTextCursor(cursor)
199+
window._find_edit.setText("quick")
200+
window._on_find_clicked()
201+
selected = window._plain_text_edit.textCursor().selectedText()
202+
check(
203+
"Find wraps from end of document",
204+
selected == "quick",
205+
f"got: {selected!r}",
206+
)
207+
208+
# --------------------------------------------------------- replace all
209+
section("Replace All")
210+
211+
rep = tmp_path / "rep.txt"
212+
rep.write_text("cat cat cat", encoding="utf-8")
213+
window._file_name_edit.setText(str(rep))
214+
window._viewmodel.load_file(str(rep))
215+
app.processEvents()
216+
217+
window._find_edit.setText("cat")
218+
window._replace_edit.setText("dog")
219+
window._on_replace_all_clicked()
220+
app.processEvents()
221+
222+
result = window._plain_text_edit.toPlainText()
223+
check(
224+
"Replace All updates all occurrences",
225+
result == "dog dog dog",
226+
f"got: {result!r}",
227+
)
228+
229+
# --------------------------------------------------------------- stubs
230+
section("Stub Actions")
231+
232+
window._convert_button.click()
233+
app.processEvents()
234+
check(
235+
"Convert shows coming-soon status",
236+
"coming soon" in window.ui.statusBar().currentMessage().lower(),
237+
)
238+
239+
# ----------------------------------------------------------------------- summary
240+
total = _pass + _fail
241+
print(f"\n{'=' * 42}")
242+
if _fail == 0:
243+
print(f"All {total} checks passed.")
244+
else:
245+
print(f"{_pass}/{total} passed — {_fail} FAILED.")
246+
print("=" * 42)
247+
248+
window.ui.close()
249+
app.quit()
250+
sys.exit(1 if _fail else 0)
251+
252+
253+
if __name__ == "__main__":
254+
main()

scripts/qt_pilot_launcher.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Qt-pilot test launcher — sets QT_IM_MODULE=none before QApplication is created.
2+
3+
Without this, QTest::keyClicks causes a SIGSEGV on headless displays (Xvfb, etc.)
4+
because there is no input method context. Setting QT_IM_MODULE=none disables the
5+
input method plugin lookup and prevents the null-pointer dereference.
6+
7+
Use with the qt-pilot MCP tool:
8+
launch_app(
9+
script_path="scripts/qt_pilot_launcher.py",
10+
working_dir="/home/chris/projects/TextTools",
11+
python_paths=["/home/chris/projects/TextTools"],
12+
)
13+
"""
14+
15+
import os
16+
17+
# Must be set before QApplication is created — do not move below imports.
18+
os.environ.setdefault("QT_IM_MODULE", "none")
19+
20+
from src.main import main # noqa: E402
21+
22+
if __name__ == "__main__":
23+
main()

0 commit comments

Comments
 (0)