aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-25 14:04:53 +0200
committerblasty <blasty@local>2026-07-25 14:04:53 +0200
commit1f1440285d8caf995abfc49c0849f3ad3000c64e (patch)
tree43710761e57eed5a64742039669086b8d954c20f /tests
parenttests: fix the "filter flake" and the view_toggle cascade — suite is 187/0 (diff)
downloadida-tui-1f1440285d8caf995abfc49c0849f3ad3000c64e.tar.gz
ida-tui-1f1440285d8caf995abfc49c0849f3ad3000c64e.tar.xz
ida-tui-1f1440285d8caf995abfc49c0849f3ad3000c64e.zip
exit: ask before quitting with unsaved database changes
Quitting used to be silent AND inconsistent. Single-binary mode closed the worker with save=False, so renames/types/comments were DROPPED without a word; project mode did the opposite and saved everything silently via the pool. Neither told you anything. Now 'q'/ctrl+q routes through action_quit: clean databases exit immediately, and anything unsaved raises a QuitScreen naming the affected databases with s save & quit d discard & quit Esc cancel Saving happens with an overlay up, because writing a large .i64 takes seconds and doing it during teardown would look like a hang with no UI left to explain it. _dirty_labels() covers project mode too: the active binary plus any still-resident one that was edited. Evicted binaries were already saved on the way out, so they can't be silently lost. on_unmount now distinguishes an explicit choice from an unexpected teardown: _save_on_exit is None (crash/kill -> save defensively, including single-binary mode which previously discarded), False (user chose discard, or we already saved). Verified end-to-end across two sessions on a temp copy: rename + 's' -> the rename is still there on reopen; rename + 'd' -> it is not. Plus a quit_guard scenario (clean exits immediately, dirty asks, Esc cancels). Also hardens Ctx.open(view="decomp"): F5/Tab only decompiles from a focused code pane and the listing may still be settling, so a swallowed Tab surfaced much later as "pseudocode view shows: active=listing". It now retries instead of assuming the first Tab takes. Full suite 191/0, green twice in a row.
Diffstat (limited to 'tests')
-rw-r--r--tests/test_scenarios.py42
1 files changed, 36 insertions, 6 deletions
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index e88547b..c1d315f 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -26,8 +26,8 @@ import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from idatui.app import ( # noqa: E402
ConfirmScreen, DecompView, DisasmView, FunctionsPanel, HexView, IdaTui,
- HelpScreen, ListingView, StringsPalette, StructEditor, SymbolPalette,
- XrefsScreen, _str_display, _word_occurrences,
+ HelpScreen, ListingView, QuitScreen, StringsPalette, StructEditor,
+ SymbolPalette, XrefsScreen, _str_display, _word_occurrences,
)
from textual.widgets import ( # noqa: E402
DataTable, Input, OptionList, Static, TextArea,
@@ -173,10 +173,17 @@ class Ctx:
await self.wait(lambda: self.lst.total > 0
and self.lst._cursor_ea() == fn.addr, t)
if view == "decomp":
- self.lst.focus()
- await self.press("tab") # F5/Tab -> decompile the function at cursor
- await self.wait(lambda: self.app._active == "decomp"
- and self.dec.loaded_ea == fn.addr, t)
+ # F5/Tab only decompiles from a focused code pane, and the listing
+ # may still be settling from the open above — a swallowed Tab used to
+ # surface much later as "pseudocode view shows: active=listing".
+ # Retry rather than assume the first one takes.
+ for _ in range(3):
+ self.lst.focus()
+ await self.pause(0.05)
+ await self.press("tab")
+ if await self.wait(lambda: self.app._active == "decomp"
+ and self.dec.loaded_ea == fn.addr, max(t / 3, 5)):
+ break
return fn
async def open_biggest(self, view="listing"):
@@ -341,6 +348,29 @@ async def s_command_palette(c: Ctx):
await c.wait(lambda: app._active != "hex", 5)
+@scenario("quit_guard")
+async def s_quit_guard(c: Ctx):
+ app = c.app
+ c.check("a clean database reports nothing unsaved", app._dirty_labels() == [],
+ f"{app._dirty_labels()}")
+ app._dirty = True # as an edit would
+ c.check("an edited database is reported unsaved",
+ len(app._dirty_labels()) == 1, f"{app._dirty_labels()}")
+ await c.press("q")
+ asked = await c.wait(lambda: isinstance(app.screen, QuitScreen), 10)
+ c.check("quitting with unsaved changes asks first", asked and app.is_running,
+ f"screen={type(app.screen).__name__} running={app.is_running}")
+ if not asked:
+ return
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(app.screen, QuitScreen), 10)
+ c.check("Esc cancels the quit and stays put",
+ app.is_running and not isinstance(app.screen, QuitScreen))
+ # leave it clean so the rest of the suite (and teardown) isn't affected
+ app._dirty = False
+ app._save_on_exit = False
+
+
@scenario("help")
async def s_help(c: Ctx):
app = c.app