aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_formats.py122
-rw-r--r--tests/test_index.py71
-rw-r--r--tests/test_pool.py29
-rw-r--r--tests/test_project.py75
-rw-r--r--tests/test_project_ui.py72
-rw-r--r--tests/test_scenarios.py87
6 files changed, 455 insertions, 1 deletions
diff --git a/tests/test_formats.py b/tests/test_formats.py
new file mode 100644
index 0000000..ab72e8d
--- /dev/null
+++ b/tests/test_formats.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""Format sniffing + load-switch construction (pure stdlib, no IDA)."""
+import os
+import sys
+import tempfile
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from idatui.formats import (PROCESSORS, load_args, # noqa: E402
+ needs_load_options, sniff)
+
+PASS = FAIL = 0
+
+
+def check(name, ok, detail=""):
+ global PASS, FAIL
+ if ok:
+ PASS += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+def main() -> int:
+ with tempfile.TemporaryDirectory() as tmp:
+ def w(name, data):
+ p = os.path.join(tmp, name)
+ with open(p, "wb") as f:
+ f.write(data)
+ return p
+
+ # -- formats IDA can load on its own: never interrupt the user -------- #
+ for name, head in (("elf", b"\x7fELF\x02\x01\x01"), ("pe", b"MZ\x90\x00"),
+ ("macho", b"\xcf\xfa\xed\xfe"), ("dex", b"dex\n035\x00"),
+ ("wasm", b"\x00asm\x01\x00")):
+ p = w(name, head + bytes(60))
+ check(f"{name} is recognised (no dialog)",
+ sniff(p) is not None and not needs_load_options(p), f"{sniff(p)}")
+
+ # -- the case this exists for ----------------------------------------- #
+ blob = w("fw.bin", bytes(range(256)) * 4)
+ check("a headerless blob is not recognised (ask)",
+ sniff(blob) is None and needs_load_options(blob))
+
+ # Intel HEX / S-records are text containers IDA does load.
+ hexf = w("f.hex", b":10010000214601360121470136007EFE09D2190140\n")
+ check("Intel HEX is recognised", sniff(hexf) == "Intel HEX", f"{sniff(hexf)}")
+ srec = w("f.s19", b"S00600004844521B\nS1130000285F245F2212226A000424290008237C\n")
+ check("Motorola S-records are recognised",
+ sniff(srec) == "Motorola S-record", f"{sniff(srec)}")
+
+ # A binary that merely STARTS with ':' is not Intel HEX. Text formats are
+ # only accepted when the whole head is printable, or half the firmware in
+ # the world gets mis-detected on one byte.
+ colon = w("colon.bin", b":\x00\xff\xfe\x01\x02" + bytes(58))
+ check("a blob starting with ':' is not mistaken for Intel HEX",
+ sniff(colon) is None, f"{sniff(colon)}")
+
+ check("an empty file is not recognised", sniff(w("empty", b"")) is None)
+ check("a missing file never asks (nothing to load)",
+ not needs_load_options(os.path.join(tmp, "nope")))
+ check("a directory never asks", not needs_load_options(tmp))
+
+ # -- switch construction ------------------------------------------------- #
+ # -b is in PARAGRAPHS: 0x8000000 >> 4 == 0x800000. Getting this wrong loads
+ # the image 16x off and every address in the database is wrong.
+ check("base is converted to paragraphs",
+ load_args("arm", 0x8000000) == "-parm -b800000",
+ load_args("arm", 0x8000000))
+ check("processor alone", load_args("mipsb") == "-pmipsb", load_args("mipsb"))
+ check("base alone", load_args("", 0x10000) == "-b1000", load_args("", 0x10000))
+ check("base 0 emits no switch (it's the default)",
+ load_args("arm", 0) == "-parm", load_args("arm", 0))
+ check("nothing in, nothing out", load_args() == "")
+ check("extra switches pass through",
+ load_args("arm", 0, "-T binary") == "-parm -T binary")
+
+ check("the processor list leads with the common targets",
+ [n for n, _ in PROCESSORS[:3]] == ["arm", "armb", "metapc"],
+ f"{[n for n, _ in PROCESSORS[:3]]}")
+
+ # Every offered name must have been checked against a real IDA, because a
+ # wrong one is REJECTED (rc=4) with nothing useful said — handing the user a
+ # dead end from inside the dialog meant to rescue them. This list is the
+ # output of tools/verify_procs.py; adding a processor without re-running it
+ # fails here on purpose.
+ VERIFIED = {
+ "arm", "armb", "metapc", "mipsl", "mipsb", "ppc", "ppcl", "sh4", "68k",
+ "riscv", "tricore", "xtensa", "avr", "z80", "tms320c6", "m32r", "arc",
+ "h8300", "sparcb", "sparcl", "s390",
+ }
+ offered = {n for n, _ in PROCESSORS}
+ check("every offered processor name is IDA-verified",
+ offered <= VERIFIED, f"unverified: {sorted(offered - VERIFIED)}")
+
+ # These are module FILENAMES or common aliases, not -p names. IDA refuses
+ # them; they were in the list until a real run said otherwise.
+ for wrong in ("h8", "sparc", "arm64", "aarch64", "mips", "m68k"):
+ check(f"{wrong!r} is not offered (IDA rejects it)", wrong not in offered)
+
+ # ...but someone WILL type them, so the labels have to carry the alias.
+ def finds(q):
+ ql = q.lower()
+ return [n for n, d in PROCESSORS if ql in n.lower() or ql in d.lower()]
+ check("typing 'arm64' still finds ARM", "arm" in finds("arm64"), f"{finds('arm64')}")
+ check("typing 'aarch64' still finds ARM", "arm" in finds("aarch64"))
+ check("typing 'm68k' still finds 68k", "68k" in finds("m68k"), f"{finds('m68k')}")
+ check("typing 'mips' finds both endiannesses",
+ set(finds("mips")) == {"mipsl", "mipsb"}, f"{finds('mips')}")
+ check("every processor entry has a human label",
+ all(n and d for n, d in PROCESSORS))
+ check("endianness is spelled out where it matters",
+ all(any(w in d.lower() for w in ("endian",))
+ for n, d in PROCESSORS if n in ("arm", "armb", "mipsb", "mipsl")))
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_index.py b/tests/test_index.py
index d1e8a48..e14f715 100644
--- a/tests/test_index.py
+++ b/tests/test_index.py
@@ -11,7 +11,8 @@ import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from idatui.index import KIND_FUNC, KIND_STRING, ProjectIndex # noqa: E402
+from idatui.index import (KIND_EXPORT, KIND_FUNC, KIND_IMPORT, # noqa: E402
+ KIND_STRING, ProjectIndex)
PASS = FAIL = 0
@@ -116,6 +117,74 @@ def main() -> int:
idx.counts() == {"libfoo": 1} and idx.search("socket bind") == [],
f"{idx.counts()}")
+ # -- cross-binary linkage join (phase 3) ------------------------------ #
+ idx.reindex("app", [
+ (KIND_FUNC, 0x1000, "main"),
+ (KIND_IMPORT, 0x2000, "strcmp"),
+ (KIND_IMPORT, 0x2008, "read"),
+ (KIND_IMPORT, 0x2010, "SSL_new"),
+ ])
+ idx.reindex("libc", [
+ (KIND_EXPORT, 0x8000, "strcmp"),
+ (KIND_EXPORT, 0x8100, "read"),
+ (KIND_EXPORT, 0x8200, "pread"),
+ (KIND_EXPORT, 0x8300, "read_line"),
+ (KIND_FUNC, 0x8000, "strcmp"),
+ ])
+ idx.reindex("libssl", [(KIND_EXPORT, 0x9000, "SSL_new")])
+
+ prov = idx.providers("strcmp", exclude="app")
+ check("an import resolves to the binary that exports it",
+ [(h.binary, h.addr) for h in prov] == [("libc", 0x8000)],
+ f"{[(h.binary, hex(h.addr)) for h in prov]}")
+
+ # The whole point of exact(): substring search would drag in pread,
+ # read_line and thread_start, and 'read' is also below the trigram floor
+ # for some engines — an import must bind to its exact name or nothing.
+ prov = idx.providers("read", exclude="app")
+ check("the join is exact, not substring",
+ [(h.binary, h.addr) for h in prov] == [("libc", 0x8100)],
+ f"{[(h.binary, h.text) for h in prov]}")
+
+ check("a short name still resolves (below the trigram floor)",
+ [h.binary for h in idx.providers("SSL_new", exclude="app")] == ["libssl"],
+ f"{idx.providers('SSL_new')}")
+
+ check("an unprovided import resolves to nothing",
+ idx.providers("dlopen", exclude="app") == [])
+
+ check("exclude keeps a binary from resolving to itself",
+ idx.providers("strcmp", exclude="libc") == [],
+ f"{idx.providers('strcmp', exclude='libc')}")
+
+ imp = idx.importers("strcmp")
+ check("the reverse join finds who imports an export",
+ [(h.binary, h.addr) for h in imp] == [("app", 0x2000)],
+ f"{[(h.binary, hex(h.addr)) for h in imp]}")
+
+ check("kind keeps functions out of the linkage join",
+ [h.binary for h in idx.providers("strcmp")] == ["libc"],
+ "a KIND_FUNC row named strcmp must not answer as an export")
+
+ idx.forget("libc")
+ check("forgetting a provider unresolves its imports",
+ idx.providers("strcmp", exclude="app") == [])
+
+ # -- ELF symbol versioning -------------------------------------------- #
+ # The importer sees strrchr@@GLIBC_2.2.5 while the provider may export a
+ # different spelling; raw names would resolve almost nothing. link_name
+ # cuts at the first '@' so both sides meet on the bare symbol.
+ from idatui.domain import link_name
+ check("link_name strips an ELF version suffix",
+ link_name("strrchr@@GLIBC_2.2.5") == "strrchr",
+ link_name("strrchr@@GLIBC_2.2.5"))
+ check("link_name leaves an unversioned name alone",
+ link_name("strrchr") == "strrchr")
+ check("link_name handles a single-@ version",
+ link_name("SSL_new@OPENSSL_3.0.0") == "SSL_new")
+ check("link_name doesn't eat a leading @",
+ link_name("@weird") == "@weird", link_name("@weird"))
+
# -- persistence ------------------------------------------------------ #
path = idx.path
idx.close()
diff --git a/tests/test_pool.py b/tests/test_pool.py
index d4347d9..5c6e2c4 100644
--- a/tests/test_pool.py
+++ b/tests/test_pool.py
@@ -155,6 +155,35 @@ def main() -> int:
check("default budget is derived, not a fixed worker count",
pool3.budget_mb >= 256, pool3.budget_mb)
+ # -- prewarm: speculative, and never at the cost of a real binary ------ #
+ with tempfile.TemporaryDirectory() as tmp:
+ proj = _mkproject(tmp, n=3)
+ made2 = {}
+
+ def spawn2(ref, ttl):
+ c = FakeClient(ref, mem=100)
+ made2[ref.label] = c
+ return c
+
+ pool = WorkerPool(proj, budget_mb=250, spawn=spawn2,
+ mem_fn=lambda c: c.mem)
+ labels = [r.label for r in proj.refs]
+ a, b, c_ = labels[0], labels[1], labels[2]
+ pool.get(a)
+ pool.set_active(a)
+ check("prewarm warms a binary when the budget has room",
+ pool.prewarm(b) is True and b in pool.resident(), f"{pool.resident()}")
+ check("prewarm is a no-op for something already resident",
+ pool.prewarm(b) is False)
+ # 2 x 100MB resident, estimate 100 more -> 300 > 250: must refuse
+ check("prewarm refuses rather than making room",
+ pool.prewarm(c_) is False and c_ not in pool.resident(),
+ f"resident={pool.resident()} mem={pool.memory_mb()}/{pool.budget_mb}")
+ check("refusing to prewarm evicts nothing",
+ set(pool.resident()) == {a, b}, f"{pool.resident()}")
+ check("prewarm ignores a label outside the project",
+ pool.prewarm("nope") is False)
+
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
diff --git a/tests/test_project.py b/tests/test_project.py
index 1f93d4a..91fd250 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -124,6 +124,41 @@ def main() -> int:
extra = _bin(os.path.join(src, "extra"))
proj2.add(extra, label="extra")
check("add() appends a binary", proj2.by_label("extra") is not None)
+
+ # -- re-adding must not duplicate (matched by resolved path) --------- #
+ n = len(proj2.refs)
+ proj2.add(extra)
+ check("re-adding the same path is a no-op", len(proj2.refs) == n,
+ f"{[r.label for r in proj2.refs]}")
+ os.chdir(src)
+ proj2.add("./extra") # same file, relative
+ proj2.add(os.path.join(src, "..", "src", "extra")) # same file, messy
+ check("a different spelling of the same path is a no-op",
+ len(proj2.refs) == n, f"{[r.label for r in proj2.refs]}")
+ link = os.path.join(src, "extra_link")
+ os.symlink(extra, link)
+ proj2.add(link)
+ check("a symlink to an existing binary is a no-op",
+ len(proj2.refs) == n, f"{[r.label for r in proj2.refs]}")
+
+ # ...but a DIFFERENT file with the same basename must still be added
+ other_dir = os.path.join(tmp, "other2")
+ os.makedirs(other_dir)
+ twin = _bin(os.path.join(other_dir, "extra"), b"\x7fELF a different extra")
+ proj2.add(twin)
+ check("a same-named file from another directory IS added",
+ len(proj2.refs) == n + 1
+ and proj2.by_source(twin) is not None
+ and proj2.by_source(extra) is not proj2.by_source(twin),
+ f"{[(r.label, r.source) for r in proj2.refs[-2:]]}")
+ check("the twins get distinct labels",
+ len({r.label for r in proj2.refs}) == len(proj2.refs),
+ f"{[r.label for r in proj2.refs]}")
+ check("create() also drops repeats on the command line",
+ len(Project.create(os.path.join(tmp, "dup.json"),
+ [extra, "./extra", extra]).refs) == 1)
+ os.chdir(tmp)
+ proj2.remove(proj2.by_source(twin).label)
check("remove() drops one", proj2.remove("extra")
and proj2.by_label("extra") is None)
@@ -150,6 +185,46 @@ def main() -> int:
except ProjectError:
check("staging a missing binary raises ProjectError", True)
+ # -- load options for headerless blobs --------------------------------- #
+ with tempfile.TemporaryDirectory() as tmp:
+ src = os.path.join(tmp, "src"); os.makedirs(src)
+ blob = os.path.join(src, "fw.bin")
+ with open(blob, "wb") as f:
+ f.write(b"\x00" * 64)
+ proj = Project.create(os.path.join(tmp, "p.json"), [blob], name="p",
+ load={"processor": "arm", "base": 0x8000000})
+ r = proj.refs[0]
+ check("create() records load options per binary",
+ r.processor == "arm" and r.base == 0x8000000,
+ f"proc={r.processor!r} base={r.base:#x}")
+ # -b is PARAGRAPHS: 0x8000000 >> 4 == 0x800000. Getting this wrong loads
+ # the image 16x too high and every address in the database is wrong.
+ check("base is converted to IDA's paragraph units",
+ r.load_args == "-parm -b800000", r.load_args)
+
+ proj2 = Project.load(proj.path)
+ check("load options survive a round-trip through the file",
+ proj2.refs[0].load_args == "-parm -b800000",
+ proj2.refs[0].load_args)
+
+ blob2 = os.path.join(src, "other.bin")
+ with open(blob2, "wb") as f:
+ f.write(b"\x00" * 64)
+ r2 = proj2.add(blob2, load={"processor": "mipsb"})
+ check("add() takes load options too",
+ r2.load_args == "-pmipsb", r2.load_args)
+
+ # a normal ELF needs none of this and must pass nothing
+ check("a binary with no load options passes no switches",
+ Project.create(os.path.join(tmp, "q.json"), [blob],
+ name="q").refs[0].load_args == "")
+
+ # addresses get written by hand, so accept how people write them
+ proj3 = Project.create(os.path.join(tmp, "r.json"), [blob], name="r",
+ load={"base": "0x1000"})
+ check("a base given as a hex STRING is parsed",
+ proj3.refs[0].base == 0x1000, f"{proj3.refs[0].base}")
+
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
diff --git a/tests/test_project_ui.py b/tests/test_project_ui.py
index 731811d..54c06bd 100644
--- a/tests/test_project_ui.py
+++ b/tests/test_project_ui.py
@@ -168,6 +168,78 @@ async def run(bins):
await pilot.press("escape")
await pilot.pause(0.2)
+ # -- a cross-binary jump is not a one-way door ----------------- #
+ # Nav history is per-binary, so arriving in another binary lands you
+ # in an empty history. Esc must fall through to the binary you came
+ # from, or a project search hit (and, since phase 3, following an
+ # import) strands you.
+ here, there = app._binary, (first if app._binary == second else second)
+ hops0 = len(app._hops)
+ target = app._index.search("main", limit=200)
+ tgt = next((h for h in target if h.binary == there), None)
+ if tgt is None:
+ check("cross-binary jump records a hop", False, "no hit in the other binary")
+ else:
+ app._switch_then_goto(tgt.binary, tgt.addr)
+ jumped = await settle(lambda: app._binary == there
+ and app._func_index is not None
+ and app._func_index.complete, 180)
+ check("a project hit switches to the other binary", jumped,
+ f"binary={app._binary} want={there}")
+ check("the jump records where it came from",
+ len(app._hops) == hops0 + 1 and app._hops[-1] == here,
+ f"hops={app._hops}")
+ # spend the local history first, then Esc must cross back
+ for _ in range(6):
+ if not app._hops or app._binary != there:
+ break
+ await pilot.press("escape")
+ await pilot.pause(0.6)
+ returned = await settle(lambda: app._binary == here, 180)
+ check("Esc crosses back to the binary the jump came from",
+ returned, f"binary={app._binary} want={here} hops={app._hops}")
+ check("the hop is consumed, not repeated",
+ not app._hops, f"hops={app._hops}")
+
+ # -- xrefs: callers in OTHER project binaries ------------------ #
+ # xrefs_to only sees this database, so an exported function looks
+ # unused from the inside even when the rest of the project calls it.
+ # The selection rule is "only for a symbol we actually export"; two
+ # executables share no linkage, so here it must stay quiet.
+ from idatui.app import XrefsScreen
+ fake = app._foreign_importers(app._cur.ea, "strrchr", None)
+ check("no cross-binary callers for a symbol this binary doesn't export",
+ fake == [], f"{fake}")
+
+ # The routing a real cross-binary caller takes: the dialog carries a
+ # (binary, addr) payload instead of a bare address, and choosing it
+ # goes through the same switch path as a search hit — hop included,
+ # so Esc comes back.
+ where_from = app._binary
+ other = first if where_from == second else second
+ hit = next((h for h in app._index.search("main", limit=200)
+ if h.binary == other), None)
+ if hit is None:
+ check("a cross-binary xref jumps to the other binary", False,
+ "no symbol found in the other binary")
+ else:
+ hops0 = len(app._hops)
+ app.push_screen(
+ XrefsScreen("xrefs to fake", [((hit.binary, hit.addr),
+ f"{hit.addr:08X} import [{hit.binary}]")]),
+ app._on_xref_chosen)
+ await settle(lambda: isinstance(app.screen, XrefsScreen), 20)
+ await pilot.press("enter")
+ jumped = await settle(lambda: app._binary == other
+ and app._func_index is not None
+ and app._func_index.complete, 180)
+ check("a cross-binary xref jumps to the other binary", jumped,
+ f"binary={app._binary} want={other}")
+ check("and records a hop so Esc returns",
+ len(app._hops) == hops0 + 1 and app._hops[-1] == where_from,
+ f"hops={app._hops}")
+ app._hops.clear()
+
# -- and the same toggle for strings --------------------------- #
from idatui.app import StringsPalette
await pilot.press("quotation_mark")
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 091ddc8..6ccbf0d 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -309,6 +309,15 @@ async def s_palette(c: Ctx):
c.check("palette matches a fuzzy subsequence",
any(n == "error" for _, _, n in pal._results),
f"results={[n for _, _, n in pal._results[:4]]}")
+ # Every other query here is lowercase, which is how a case bug hid for so
+ # long: the name was lowered but the query wasn't, so ONE capital matched
+ # nothing. Invisible on lowercase C symbols, fatal on a library that
+ # capitalises (PEM_read_bio found 0 of 10093 functions in libcrypto).
+ pinp.value = "MAIN"
+ await c.wait(lambda: any(n == "main" for _, _, n in pal._results), 10)
+ c.check("palette matching is case-insensitive in BOTH directions",
+ any(n == "main" for _, _, n in pal._results),
+ f"results={[n for _, _, n in pal._results[:4]]}")
pinp.value = "main"
await c.wait(lambda: pal._results and pal._results[0][2] == "main", 10)
want = pal._results[0][1]
@@ -318,6 +327,21 @@ async def s_palette(c: Ctx):
c.check("selecting a palette entry opens that function",
bool(app._cur) and app._cur.ea == want,
f"cur={app._cur.ea if app._cur else None}")
+ # Re-open the function we are ALREADY standing on. That used to append an
+ # identical nav entry, and the extra Esc it bought popped the stack without
+ # changing anything on screen — a dead keypress, which is precisely what
+ # "back is broken" feels like from the keyboard.
+ depth = len(app._nav)
+ await c.press("ctrl+n")
+ await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10)
+ pal2 = app.screen
+ pal2.query_one(Input).value = "main"
+ await c.wait(lambda: pal2._results and pal2._results[0][2] == "main", 10)
+ await c.press("enter")
+ await c.wait(lambda: not isinstance(app.screen, SymbolPalette), 10)
+ await c.pause(0.4)
+ c.check("re-opening the current function doesn't stack a duplicate",
+ len(app._nav) == depth, f"nav {depth} -> {len(app._nav)}")
await c.press("ctrl+n")
await c.wait(lambda: isinstance(app.screen, SymbolPalette), 10)
await c.press("escape")
@@ -325,6 +349,52 @@ async def s_palette(c: Ctx):
c.check("Esc closes the palette", not isinstance(app.screen, SymbolPalette))
+@scenario("load_options")
+async def s_load_options(c: Ctx):
+ """The dialog must never appear for a file IDA can load itself — the whole
+ suite runs on an ELF, so a false positive here would block every run."""
+ from idatui.app import LoadOptionsScreen
+ app = c.app
+ c.check("no load dialog for a recognised binary",
+ not isinstance(app.screen, LoadOptionsScreen),
+ f"screen={type(app.screen).__name__}")
+ c.check("and the app agrees it shouldn't ask",
+ not app._should_ask_load_options())
+ # The dialog itself, driven directly: it has to come back with switches the
+ # worker can use, and -b has to be paragraphs.
+ from idatui.formats import load_args, needs_load_options, sniff
+ c.check("the running target sniffs as a real format",
+ sniff(app._open_path) is not None and not needs_load_options(app._open_path),
+ f"{sniff(app._open_path)}")
+ c.check("dialog output converts a base to paragraphs",
+ load_args("arm", 0x8000000) == "-parm -b800000")
+
+ # Tab is a PRIORITY app binding (disasm<->pseudocode), so it fired even with
+ # a modal up and nothing in a dialog could be tabbed to. That is why the load
+ # dialog's address field was unreachable — and it was broken in every other
+ # modal too.
+ from idatui.app import LoadOptionsScreen
+ app.push_screen(LoadOptionsScreen("/tmp/probe.bin", 1234))
+ await c.wait(lambda: isinstance(app.screen, LoadOptionsScreen), 10)
+ sc = app.screen
+ first = app.focused
+ await c.press("tab")
+ await c.pause(0.2)
+ c.check("Tab moves focus inside a modal instead of toggling the view",
+ app.focused is not first and isinstance(app.screen, LoadOptionsScreen),
+ f"focus={getattr(app.focused, 'id', None)}")
+ c.check("Tab in the load dialog lands on the address field",
+ getattr(app.focused, "id", None) == "load-base",
+ f"focus={getattr(app.focused, 'id', None)}")
+ await c.press("tab")
+ await c.pause(0.2)
+ c.check("Tab again returns to the processor filter",
+ getattr(app.focused, "id", None) == "pal-input",
+ f"focus={getattr(app.focused, 'id', None)}")
+ await c.press("escape")
+ await c.wait(lambda: not isinstance(app.screen, LoadOptionsScreen), 10)
+
+
@scenario("command_palette")
async def s_command_palette(c: Ctx):
app = c.app
@@ -887,6 +957,23 @@ async def s_view_toggle(c: Ctx):
styled = any(seg.style is not None and seg.style.color is not None
for strip in dec._strips[:min(dec.total, 200)] for seg in strip)
c.check("pseudocode is syntax-highlighted", styled)
+ # Cancelling a prompt must hand focus back to the pane you were READING.
+ # _code_view() used to choose on _pref, which was only ever "listing", so it
+ # focused the hidden listing and the pseudocode stopped answering the
+ # keyboard — arrows did nothing at all until you clicked.
+ dec.focus()
+ await c.pause(0.05)
+ line0 = dec.cursor
+ await c.press("g")
+ await c.wait(lambda: app.query_one("#goto", Input).display, 10)
+ await c.press("escape")
+ await c.wait(lambda: not app.query_one("#goto", Input).display, 10)
+ await c.press("down")
+ await c.press("down")
+ await c.pause(0.2)
+ c.check("cancelling goto leaves focus in the pseudocode (arrows still work)",
+ dec.cursor > line0,
+ f"cursor {line0} -> {dec.cursor} focus={type(app.focused).__name__}")
dec.focus() # Tab only toggles the view from a code pane; elsewhere it's
await c.pause(0.05) # focus-next, which would silently leave us in decomp
await c.press("tab")