aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_kittygfx.py152
1 files changed, 152 insertions, 0 deletions
diff --git a/tests/test_kittygfx.py b/tests/test_kittygfx.py
new file mode 100644
index 0000000..b12817d
--- /dev/null
+++ b/tests/test_kittygfx.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+"""Kitty graphics escapes: what we actually send to the terminal (no IDA).
+
+The splash re-anchors itself on every progress note, so the escape it sends has
+to be a REPLACEMENT, not another copy. That is one key (``p``) and it is
+invisible in every screenshot, which is exactly why it needs a test.
+"""
+
+#: pure stdlib escape-construction checks; no IDA, no Textual.
+#: Read by tests/run.py (--fast skips every NEEDS_IDA file).
+NEEDS_IDA = False
+import os
+import re
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from idatui import kittygfx # noqa: E402
+
+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}")
+
+
+class Tty:
+ """Capture what kittygfx writes, in place of the real stdout."""
+
+ def __init__(self):
+ self.sent = []
+ self._real = kittygfx._write
+
+ def __enter__(self):
+ kittygfx._write = lambda data: (self.sent.append(data), True)[1]
+ return self
+
+ def __exit__(self, *exc):
+ kittygfx._write = self._real
+
+ @property
+ def blob(self):
+ return "".join(self.sent)
+
+ def cmds(self, action):
+ """Every graphics command with the given ``a=`` action."""
+ return [c for c in re.findall(r"\x1b_G([^;\x1b]*)", self.blob)
+ if f"a={action}" in c.split(",")]
+
+
+def keys(cmd):
+ return dict(kv.split("=", 1) for kv in cmd.split(",") if "=" in kv)
+
+
+def main() -> int:
+ kittygfx._uploaded[kittygfx.LOGO_ID] = (768, 801) # pretend it's uploaded
+
+ # -- the bug: anonymous placements STACK ------------------------------- #
+ # A placement is identified by (image id, placement id). With no p key
+ # every place() adds another copy at the same cell: a long load left the
+ # terminal compositing hundreds of copies of an RGBA image over itself.
+ with Tty() as tty:
+ for _ in range(50):
+ kittygfx.place(4, 10, 60, 26)
+ placements = tty.cmds("p")
+ check("place() emits one command per call", len(placements) == 50,
+ f"{len(placements)}")
+ check("every placement carries a placement id (replaces, not stacks)",
+ all("p" in keys(c) for c in placements),
+ f"{placements[0] if placements else '(none)'}")
+ check("the placement id is the same every time (one image on screen)",
+ len({keys(c)["p"] for c in placements}) == 1,
+ f"{sorted({keys(c).get('p') for c in placements})}")
+ check("...and it is non-zero (p=0 means anonymous)",
+ keys(placements[0])["p"] not in ("0", ""), f"{placements[0]}")
+
+ # -- the rest of the escape still says what it used to ------------------ #
+ with Tty() as tty:
+ ok = kittygfx.place(4, 10, 60, 26)
+ k = keys(tty.cmds("p")[0])
+ check("place() reports success", ok)
+ check("image id, source pixels and cell box are unchanged",
+ (k["i"], k["s"], k["v"], k["c"], k["r"])
+ == (str(kittygfx.LOGO_ID), "768", "801", "60", "26"), f"{k}")
+ check("the terminal is told not to move the cursor (C=1)",
+ k.get("C") == "1", f"{k}")
+ check("the cursor is saved and restored around the placement",
+ tty.blob.startswith("\x1b[s") and tty.blob.endswith("\x1b[u"),
+ repr(tty.blob[:8] + "..." + tty.blob[-8:]))
+ check("the placement is positioned 1-based (row 4 -> line 5)",
+ "\x1b[5;11H" in tty.blob, repr(tty.blob[:24]))
+
+ # -- deleting still removes EVERY placement of the image ---------------- #
+ # d=i is by image id, so it takes the placement with us regardless of p.
+ with Tty() as tty:
+ kittygfx.clear()
+ k = keys(tty.cmds("d")[0])
+ check("clear() deletes by image id (d=i), keeping the upload",
+ k.get("d") == "i" and k.get("i") == str(kittygfx.LOGO_ID), f"{k}")
+ check("clear() does not free the image data (lowercase d)",
+ kittygfx.is_uploaded(), "upload was dropped")
+
+ with Tty() as tty:
+ kittygfx.delete()
+ k = keys(tty.cmds("d")[0])
+ check("delete() frees the image data too (d=I)", k.get("d") == "I", f"{k}")
+ check("...and forgets the upload, so the next place() refuses",
+ not kittygfx.is_uploaded() and kittygfx.place(0, 0, 10, 10) is False)
+
+ # -- refusals ----------------------------------------------------------- #
+ kittygfx._uploaded[kittygfx.LOGO_ID] = (768, 801)
+ with Tty() as tty:
+ check("a zero-sized box is refused, not sent",
+ kittygfx.place(0, 0, 0, 10) is False
+ and kittygfx.place(0, 0, 10, 0) is False and not tty.sent,
+ f"{tty.sent}")
+ kittygfx._uploaded.pop(kittygfx.LOGO_ID, None)
+
+ # -- fit(): aspect ratio against non-square cells ----------------------- #
+ check("fit() keeps the aspect ratio for 9x22 cells",
+ kittygfx.fit((768, 801), 60, 99, cell=(9, 22)) == (60, 26),
+ f"{kittygfx.fit((768, 801), 60, 99, cell=(9, 22))}")
+ check("fit() shrinks to the row budget instead of overflowing",
+ kittygfx.fit((768, 801), 60, 10, cell=(9, 22))[1] == 10,
+ f"{kittygfx.fit((768, 801), 60, 10, cell=(9, 22))}")
+ check("fit() never returns a zero dimension",
+ all(v >= 1 for v in kittygfx.fit((768, 801), 1, 1, cell=(9, 22))))
+ check("fit() survives a degenerate image size",
+ kittygfx.fit((0, 0), 60, 26) == (60, 26))
+
+ # -- png_size() reads the header, not the pixels ------------------------ #
+ logo = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "logo.png")
+ if os.path.exists(logo):
+ check("png_size() reads logo.png's IHDR",
+ kittygfx.png_size(logo) == (768, 801), f"{kittygfx.png_size(logo)}")
+ check("png_size() returns None for a non-PNG", kittygfx.png_size(__file__) is None)
+ check("png_size() returns None for a missing file",
+ kittygfx.png_size("/nonexistent/nope.png") is None)
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())