aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 15:16:06 +0200
committeruser <user@clank>2026-08-14 15:16:06 +0200
commitc20b226d44af64ac476173361dad9676dbc74043 (patch)
tree00a549e550361cd34461fa0393b9cc73fc65543b
parentsl0ppty: the resize hint names the verb (diff)
downloadslosh-c20b226d44af64ac476173361dad9676dbc74043.tar.gz
slosh-c20b226d44af64ac476173361dad9676dbc74043.tar.xz
slosh-c20b226d44af64ac476173361dad9676dbc74043.zip
sl0ppty: the test cycle was 80s, it is now 5.5s
Measured before changing anything, because the obvious suspect was wrong: the build is not slow. zig caches objects, so even a clean rebuild is ~0.15s and an incremental one is 0.08s. The C unit tests are 0.00s. All eighty seconds were in the python suite, and almost all of that was waiting on durations that were guesses. Four things, in order of what they bought. Run the python tests in parallel. They are separate processes that share nothing — script mode opens no socket — so each is now its own make target with a stamp, and `make -j` runs them at once. 78s -> 21s for free. The stamps are worth as much as the parallelism day to day: an untouched test whose binary has not changed does not run again, so `make test` after editing one test file is 1.1s and after editing nothing is 0.02s. The binary is a prerequisite of every stamp, so changing the code still re-runs everything. Then stop guessing at durations. `settle N` is the only thing that reads a pane's pty, so it is how you wait for a program's output — but `send` and `snapshot` are already synchronous with the app, which means a settle after a mouse event, waiting to assert on our own chrome, is waiting for nothing at all. Those are removed. Where a program's output really is the thing being waited for, harness.until()/until_text() polls in 10ms steps and returns the instant it lands: measured, that is one poll, against the 200-300ms those calls used to donate. The default settle drops 120ms -> 20ms on the same reasoning. Three effects are gated on a real clock rather than on anything observable — the hover dwell, and a toast's lifetime. Those genuinely have to wait, so the tests that are not *about* the timing now configure the clock down instead (hover_delay_ms 20, toast_ms 150) and the two tests that are about it keep the real defaults, because a dwell test that cannot observe not-yet-elapsed proves nothing. Two tests turned out to be passing by accident, which is the useful kind of thing a speedup shakes loose: both asserted the resize hint was gone after a drop while the pointer was still sitting on the handle. It was only gone because the dwell had not re-elapsed — resting on a handle *should* re-arm the hint. They now move off the handle first and assert the thing they meant. Unrelated but found on the way: the Makefile tracked no header dependencies, so editing a .h rebuilt nothing and the only safe move was `make clean`. -MMD -MP fixes it; touching shader.h now rebuilds exactly the six objects that include it. That one was a correctness bug wearing a performance costume. .fastfeedback/SPEED.md records the measurements and how to write a fast test here, so the next person does not rediscover that settle-after-a-click is free to delete.
-rw-r--r--.fastfeedback/SPEED.md48
-rw-r--r--Makefile44
-rw-r--r--tests/harness.py34
-rw-r--r--tests/test_clipboard.py12
-rw-r--r--tests/test_drag.py56
-rw-r--r--tests/test_shaders.py69
-rw-r--r--tests/test_split_ux.py40
7 files changed, 208 insertions, 95 deletions
diff --git a/.fastfeedback/SPEED.md b/.fastfeedback/SPEED.md
new file mode 100644
index 0000000..59b4897
--- /dev/null
+++ b/.fastfeedback/SPEED.md
@@ -0,0 +1,48 @@
+# SPEED — sl0ppty
+
+All numbers measured on this box, 2026-08-14. Re-measure only if something
+structural changes.
+
+| what | command | time |
+|---|---|---|
+| build (incremental) | `make all -j8` | ~0.1s |
+| build (clean) | `make clean && make all -j8` | ~0.15s (zig caches objects) |
+| C unit tests | `./build/{input,kdl,shader}_test` | ~0.00s |
+| one python test | `cd tests && python3 test_shaders.py` | 0.6–7s |
+| **fast suite** | `make test` | **~5.5s** (parallel, `-j$(nproc)`) |
+| fast suite, nothing changed | `make test` | ~0.02s (per-file stamps) |
+| fast suite, one test touched | `make test` | ~1.1s |
+| force re-run everything | `make retest` | ~5.5s |
+| live suite (real ptys) | `make test-live` | ~5.7s |
+
+## Notes
+
+- `make test` runs each `tests/test_*.py` as its own make target, so they run
+ in parallel and an unchanged test whose binary has not changed is skipped.
+ Stamps live in `build/.pass-*`, logs in `build/.log-*`.
+- Touching a **header** does rebuild the objects that include it (`-MMD -MP`).
+ There is no reason to `make clean`; a clean build is 0.15s anyway.
+- `test_session.py` is a *live* test (real pty) and is excluded from `make
+ test` on purpose — it runs in `make test-live`.
+
+## Writing fast tests here
+
+`settle N` is the only thing that reads a pane's pty, but it is a **duration**,
+i.e. a guess. `send` and `snapshot` are already synchronous with the app, so:
+
+- asserting on **our own chrome** (frames, guides, shaders, hints) after a
+ mouse or key event needs **no settle at all**;
+- waiting for a **program's output** should use `s.until_text("...")` or
+ `s.until(pred)`, which pump in 10ms steps and return the instant the
+ condition holds;
+- anything gated on a **real clock** (hover dwell, toast expiry) is a genuine
+ wait — so configure the clock down (`hover_delay_ms 20`, `toast_ms 150`)
+ rather than sleeping for the default.
+
+Getting this wrong is what made the suite 80s. It is 5.5s now.
+
+## Not a trap
+
+`speedscan` flags `make test` as spinning up docker/compose. It does not —
+that is a false positive from the word `compose` (as in *compositing a
+screen*) appearing all over this codebase.
diff --git a/Makefile b/Makefile
index f1618ec..acfcf48 100644
--- a/Makefile
+++ b/Makefile
@@ -10,15 +10,30 @@ VT_OUT := $(VT)/zig-out
VT_LIB := $(VT_OUT)/lib/libghostty-vt.a
VT_INC := $(VT_OUT)/include
-CFLAGS := -std=c23 -O1 -g -Wall -Wextra -Wno-unused-parameter \
+# -MMD -MP emits a .d per object listing the headers it included, so editing a
+# header rebuilds what actually uses it. Without this a header edit rebuilt
+# nothing and the only safe move was `make clean`, which is how a 0.1s build
+# turns into a habit of throwing the whole thing away.
+CFLAGS := -std=c23 -O1 -g -Wall -Wextra -Wno-unused-parameter -MMD -MP \
-I$(VT_INC) -Isrc
LDFLAGS :=
SRC := $(wildcard src/*.c)
OBJ := $(SRC:src/%.c=build/%.o)
+DEPS := $(OBJ:.o=.d)
BIN := build/sl0ppty
-.PHONY: all clean vendor run test test-live test-all smoke help
+# One stamp per python test file. The stamp depends on the file, the harness and
+# the binary, so an untouched test whose binary has not changed does not run
+# again — and `make -j` runs the rest at once, because they are separate
+# processes that share nothing (script mode opens no socket).
+# test_session.py drives a real pty and belongs to test-live; it is only named
+# test_* by history.
+PY_TESTS := $(filter-out test_session.py,$(notdir $(wildcard tests/test_*.py)))
+PY_STAMPS := $(PY_TESTS:%.py=build/.pass-%)
+JOBS ?= $(shell nproc 2>/dev/null || echo 4)
+
+.PHONY: all clean vendor run test retest test-live test-all smoke help
.DEFAULT_GOAL := help
help: ## show this
@@ -56,16 +71,21 @@ SHADER_TEST := build/shader_test
$(SHADER_TEST): tests/shader_test.c src/shader.c src/screen.c src/json.c $(VT_LIB) | build
$(CC) $(CFLAGS) tests/shader_test.c src/shader.c src/screen.c src/json.c $(VT_LIB) -o $@
+build/.pass-%: tests/%.py tests/harness.py $(BIN) | build
+ @cd tests && timeout 300 python3 $(notdir $<) > ../build/.log-$* 2>&1 \
+ || { echo "FAIL $(notdir $<)"; tail -25 ../build/.log-$*; exit 1; }
+ @printf ' ok %-24s %s\n' "$(notdir $<)" "$$(grep -c '^ok' build/.log-$* 2>/dev/null || echo ?) checks"
+ @touch $@
+
test: $(BIN) $(TEST_BIN) $(KDL_TEST) $(SHADER_TEST) ## unit + headless checks (fast)
- ./$(TEST_BIN)
- @echo
- ./$(KDL_TEST)
- @echo
- ./$(SHADER_TEST)
- @echo
- cd tests && python3 test_screen.py && python3 test_layout.py && python3 test_tabs.py && python3 test_osc5577.py && \
- python3 test_responsive.py && python3 test_finder.py && \
- python3 test_config.py && python3 test_layout_files.py && python3 test_drag.py && python3 test_hover.py && python3 test_scroll.py && python3 test_clipboard.py && python3 test_split_ux.py && python3 test_graphics.py && python3 test_pane_title.py && python3 test_shaders.py
+ @./$(TEST_BIN) >/dev/null && ./$(KDL_TEST) >/dev/null && ./$(SHADER_TEST) >/dev/null \
+ && printf ' ok %-24s %s\n' "C unit tests" "3 binaries"
+ @$(MAKE) --no-print-directory -j$(JOBS) $(PY_STAMPS)
+ @printf '\nall green\n'
+
+retest: ## force every python test to run again
+ @rm -f build/.pass-*
+ @$(MAKE) --no-print-directory test
test-live: $(BIN) ## the checks that need a real tty (slow)
python3 tests/live_m0.py
@@ -84,3 +104,5 @@ run: $(BIN) ## build and run
clean: ## remove our build output (not the vendor lib)
rm -rf build
+
+-include $(DEPS)
diff --git a/tests/harness.py b/tests/harness.py
index 6b61167..bbd2c25 100644
--- a/tests/harness.py
+++ b/tests/harness.py
@@ -11,6 +11,7 @@ M1 on is tested through it.
import json
import os
import subprocess
+import time
BIN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "build", "sl0ppty")
@@ -112,9 +113,40 @@ class Session:
"""Bytes straight into the pane's pty, decoder bypassed."""
self._cmd(f"raw {escaped}")
- def settle(self, ms=120):
+ def settle(self, ms=20):
+ """Pump every pane until none has produced output for `ms`.
+
+ The default is deliberately short. `settle` is a *duration*, and a
+ duration is a guess: too long and every call donates dead time to every
+ run forever, too short and it races. Where the answer is observable,
+ use until()/until_text() instead and stop guessing.
+ """
self._cmd(f"settle {ms}")
+ def until(self, pred, timeout_ms=3000, step_ms=10):
+ """Pump until pred(snapshot) holds, then return that snapshot.
+
+ `settle N` is the only thing that reads a pane's pty, so waiting for a
+ program's output means pumping — but it is also a *duration*, and a
+ duration is a guess about something observable. This polls the thing
+ itself and returns the instant it is true, which is both faster than a
+ generous settle and safer than a tight one: the deadline only bounds
+ failure, never success.
+
+ Returns the last snapshot either way, so a caller's own check reports
+ what was actually on screen rather than a timeout from in here.
+ """
+ deadline = time.monotonic() + timeout_ms / 1000.0
+ while True:
+ self.settle(step_ms)
+ snap = self.snapshot()
+ if pred(snap) or time.monotonic() >= deadline:
+ return snap
+
+ def until_text(self, needle, **kw):
+ """The common case: wait for a program to have printed something."""
+ return self.until(lambda snap: needle in snap.screen(), **kw)
+
def resize(self, cols, rows):
self.cols, self.rows = cols, rows
self._cmd(f"resize {cols} {rows}")
diff --git a/tests/test_clipboard.py b/tests/test_clipboard.py
index 0dd5fc9..356d775 100644
--- a/tests/test_clipboard.py
+++ b/tests/test_clipboard.py
@@ -111,7 +111,13 @@ def test_a_program_can_copy():
def test_toasts():
- with Session(SH, cols=50, rows=10) as s:
+ # A toast's lifetime is a real duration, so this really does wait for it -
+ # but the duration is configurable, so it waits 150ms instead of 2500.
+ import tempfile
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ f.write("toast_ms 150\n")
+ f.close()
+ with Session(SH, cols=50, rows=10, config=f.name) as s:
s.settle()
r = s.api("notify", text="build finished")
check("notify is accepted", r["ok"], str(r))
@@ -126,8 +132,8 @@ def test_toasts():
"build finished" in snap.screen() and "second thing" in snap.screen(),
repr(snap.screen()[-200:]))
- time.sleep(2.8)
- s.settle(60)
+ time.sleep(0.25)
+ s.settle(20)
check("and they expire on their own",
"build finished" not in s.snapshot().screen(),
repr(s.snapshot().screen()[-120:]))
diff --git a/tests/test_drag.py b/tests/test_drag.py
index b6a4cfa..618abe6 100644
--- a/tests/test_drag.py
+++ b/tests/test_drag.py
@@ -10,6 +10,27 @@ import time
from harness import Session, check, report
+def _cfg(text):
+ import tempfile
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ f.write(text)
+ f.close()
+ return f.name
+
+
+# The hint is gated on a real clock, so the wait is real - just a short one.
+FAST = _cfg("hover_delay_ms 20\n")
+def _cfg(text):
+ import tempfile
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ f.write(text)
+ f.close()
+ return f.name
+
+
+# The resize hint arms on a real clock, so its tests really do wait - but only
+# the ones *about* the dwell need the default 250ms to prove anything.
+FAST = _cfg("hover_delay_ms 20\n")
SH = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; stty raw -echo; cat']
@@ -18,7 +39,7 @@ def widths(s):
def test_keyboard_resize():
- with Session(SH, cols=80, rows=16) as s:
+ with Session(SH, cols=80, rows=16, config=FAST) as s:
s.settle()
s.key("\\\\")
s.settle()
@@ -47,7 +68,7 @@ def test_keyboard_resize():
def test_vertical_resize():
- with Session(SH, cols=80, rows=20) as s:
+ with Session(SH, cols=80, rows=20, config=FAST) as s:
s.settle()
s.key("-")
s.settle()
@@ -66,7 +87,7 @@ def test_vertical_resize():
def test_resize_survives_a_collapse_cycle():
- with Session(SH, cols=100, rows=20) as s:
+ with Session(SH, cols=100, rows=20, config=FAST) as s:
s.settle()
s.key("\\\\")
s.settle()
@@ -221,6 +242,8 @@ ARROW_V, ARROW_H = "\u21d4", "\u21d5" # which way that boundary travels
def test_the_gap_says_it_is_a_handle():
+ # No FAST here: this is the test that proves contact alone is not enough,
+ # so it needs a dwell long enough to be observably not-yet-elapsed.
with Session(SH, cols=120, rows=26) as s:
s.settle()
s.key("\\\\")
@@ -266,16 +289,19 @@ def test_the_hint_changes_while_resizing():
GRAB_V in snap.screen() and GRIP_V not in snap.screen(), "")
s.send(rf"\e[<0;{gx + 6};{gy + 1}m")
- s.settle(150)
- snap = s.snapshot()
+ s.settle(60)
check("and the boundary actually moved",
s.panes()[0]["w"] > before, f'{before} -> {s.panes()[0]["w"]}')
- check("both hints are gone once it is dropped",
+ # Off the handle: still resting on it would (correctly) re-arm the
+ # dotted hint, which is the pointer following the mouse, not a leak.
+ s.send(rf"\e[<35;{left["x"] + 3};{gy + 1}M")
+ snap = s.snapshot()
+ check("both hints are gone once it is dropped and left alone",
GRAB_V not in snap.screen() and GRIP_V not in snap.screen(), "")
def test_a_horizontal_boundary_uses_horizontal_marks():
- with Session(SH, cols=120, rows=26) as s:
+ with Session(SH, cols=120, rows=26, config=FAST) as s:
s.settle()
s.key("-") # split into rows
s.settle(200)
@@ -283,7 +309,7 @@ def test_a_horizontal_boundary_uses_horizontal_marks():
gx, gy = top["x"] + 6, top["y"] + top["h"]
s.send(rf"\e[<35;{gx + 1};{gy + 1}M")
- time.sleep(0.35)
+ time.sleep(0.06)
s.settle(60)
snap = s.snapshot()
check("a row boundary hints along its own axis",
@@ -295,7 +321,7 @@ def test_a_horizontal_boundary_uses_horizontal_marks():
def test_no_resize_hint_during_another_drag():
"""A pointer already carrying a pane is not shopping for a boundary."""
- with Session(SH, cols=120, rows=26) as s:
+ with Session(SH, cols=120, rows=26, config=FAST) as s:
s.settle()
s.key("\\\\")
s.settle(200)
@@ -304,7 +330,7 @@ def test_no_resize_hint_during_another_drag():
s.send(rf"\e[<0;{left['x'] + 5};{left['y'] + 1}M") # grab the title
s.send(rf"\e[<32;{gx + 1};{gy + 1}M") # drag over the gap
- time.sleep(0.35)
+ time.sleep(0.06)
s.settle(80)
check("a title drag over a gap arms nothing",
GRIP_V not in s.snapshot().screen(), "")
@@ -314,7 +340,7 @@ def test_no_resize_hint_during_another_drag():
def test_the_gap_names_the_verb():
"""The dots say it is a handle; the arrow says what pulling it does."""
- with Session(SH, cols=120, rows=26) as s:
+ with Session(SH, cols=120, rows=26, config=FAST) as s:
s.settle()
s.key("\\\\")
s.settle(200)
@@ -322,7 +348,7 @@ def test_the_gap_names_the_verb():
gx, gy = left["x"] + left["w"], left["y"] + 3
s.send(rf"\e[<35;{gx + 1};{gy + 1}M")
- time.sleep(0.35)
+ time.sleep(0.06)
s.settle(60)
snap = s.snapshot()
check("hovering a column boundary shows a sideways arrow",
@@ -343,13 +369,13 @@ def test_the_gap_names_the_verb():
check("it stays while the boundary is actually moving",
ARROW_V in s.snapshot().screen(), "")
s.send(rf"\e[<0;{gx + 5};{gy + 1}m")
- s.settle(120)
+ s.send(rf"\e[<35;{left["x"] + 3};{gy + 1}M") # and off the handle
check("and goes with the rest of the hint",
ARROW_V not in s.snapshot().screen(), "")
def test_a_row_boundary_names_its_own_verb():
- with Session(SH, cols=120, rows=26) as s:
+ with Session(SH, cols=120, rows=26, config=FAST) as s:
s.settle()
s.key("-")
s.settle(200)
@@ -357,7 +383,7 @@ def test_a_row_boundary_names_its_own_verb():
gx, gy = top["x"] + 6, top["y"] + top["h"]
s.send(rf"\e[<35;{gx + 1};{gy + 1}M")
- time.sleep(0.35)
+ time.sleep(0.06)
s.settle(60)
snap = s.snapshot()
check("a row boundary shows an up-down arrow",
diff --git a/tests/test_shaders.py b/tests/test_shaders.py
index b3e92ee..b1e89f6 100644
--- a/tests/test_shaders.py
+++ b/tests/test_shaders.py
@@ -79,9 +79,8 @@ def release(s, x, y):
def test_off_by_default():
with Session(SH, cols=80, rows=14) as s:
- s.settle(200)
+ s.until_text("BLOCK")
split(s)
- s.settle(200)
focused, other = by_focus(s)
snap = s.snapshot()
check("by default an unfocused pane is not dimmed",
@@ -93,9 +92,8 @@ def test_off_by_default():
def test_dims_everything_but_the_focused_pane():
with Session(SH, cols=80, rows=14, config=cfg(unfocused_cfg(128))) as s:
- s.settle(200)
+ s.until_text("BLOCK")
split(s)
- s.settle(200)
focused, other = by_focus(s)
snap = s.snapshot()
check("the focused pane keeps its colour",
@@ -111,13 +109,11 @@ def test_dims_everything_but_the_focused_pane():
def test_the_dimming_follows_focus():
with Session(SH, cols=80, rows=14, config=cfg(unfocused_cfg(128))) as s:
- s.settle(200)
+ s.until_text("BLOCK")
split(s)
- s.settle(200)
first_focused, first_other = by_focus(s)
s.api("focus", id=first_other["id"])
- s.settle(200)
snap = s.snapshot()
now_focused = [p for p in s.panes() if p["id"] == first_other["id"]][0]
now_other = [p for p in s.panes() if p["id"] == first_focused["id"]][0]
@@ -131,9 +127,8 @@ def test_the_dimming_follows_focus():
def test_dimming_never_touches_the_chrome():
with Session(SH, cols=80, rows=14, config=cfg(unfocused_cfg(200))) as s:
- s.settle(200)
+ s.until_text("BLOCK")
split(s)
- s.settle(200)
_, other = by_focus(s)
snap = s.snapshot()
check("a dimmed pane's frame keeps its full colour",
@@ -146,13 +141,11 @@ def test_dimming_never_touches_the_chrome():
def test_dragging_greys_the_other_panes():
with Session(SH, cols=90, rows=16) as s:
- s.settle(200)
+ s.until_text("BLOCK")
left, right = split(s)
- s.settle(200)
press(s, left["x"] + 4, left["y"])
motion(s, right["x"] + 5, right["y"] + 3)
- s.settle(120)
snap = s.snapshot()
check("the pane being dragged keeps its colour",
content_fg(snap, left) == GREEN, str(content_fg(snap, left)))
@@ -170,7 +163,6 @@ def test_dragging_greys_the_other_panes():
ch[0] == ch[2], str(grey))
release(s, right["x"] + 5, right["y"] + 3)
- s.settle(200)
snap = s.snapshot()
for p in s.panes():
check(f"pane {p['id']} is back to its own colour after the drop",
@@ -180,9 +172,8 @@ def test_dragging_greys_the_other_panes():
def test_full_strength_is_a_true_grey():
with Session(SH, cols=90, rows=16,
config=cfg(drag_cfg("grayscale amount=255"))) as s:
- s.settle(200)
+ s.until_text("BLOCK")
left, right = split(s)
- s.settle(200)
press(s, left["x"] + 4, left["y"])
motion(s, right["x"] + 5, right["y"] + 3)
@@ -197,12 +188,10 @@ def test_full_strength_is_a_true_grey():
def test_a_press_that_never_moves_greys_nothing():
"""A click on a title is a click. It must not flash the session grey."""
with Session(SH, cols=90, rows=16) as s:
- s.settle(200)
+ s.until_text("BLOCK")
left, right = split(s)
- s.settle(200)
press(s, left["x"] + 4, left["y"])
- s.settle(120)
snap = s.snapshot()
check("pressing without moving greys nothing",
content_fg(snap, right) == GREEN, str(content_fg(snap, right)))
@@ -213,15 +202,13 @@ def test_a_press_that_never_moves_greys_nothing():
def test_the_drag_greying_outranks_the_focus_dimming():
"""Two reasons to be grey would compound into one muddy grey."""
with Session(SH, cols=90, rows=16, config=cfg(unfocused_cfg(128))) as s:
- s.settle(200)
+ s.until_text("BLOCK")
left, right = split(s)
- s.settle(200)
# Drag the *unfocused* pane, so focus policy would dim it if it applied.
focused, other = by_focus(s)
press(s, other["x"] + 4, other["y"])
motion(s, focused["x"] + 5, focused["y"] + 3)
- s.settle(120)
snap = s.snapshot()
check("the dragged pane lifts off the page, dimming or not",
content_fg(snap, other) == GREEN, str(content_fg(snap, other)))
@@ -232,12 +219,10 @@ def test_the_drag_greying_outranks_the_focus_dimming():
def drag_and_sample(conf):
"""Colour of a non-dragged pane's content mid-drag, under `conf`."""
with Session(SH, cols=90, rows=16, config=cfg(conf)) as s:
- s.settle(200)
+ s.until_text("BLOCK")
left, right = split(s)
- s.settle(200)
press(s, left["x"] + 4, left["y"])
motion(s, right["x"] + 5, right["y"] + 3)
- s.settle(120)
got = content_fg(s.snapshot(), right)
release(s, right["x"] + 5, right["y"] + 3)
s.settle(120)
@@ -280,9 +265,8 @@ SOLID_H = "\u2500"
def test_non_dragged_panes_get_a_dashed_border():
with Session(SH, cols=90, rows=16) as s:
- s.settle(200)
+ s.until_text("BLOCK")
left, right = split(s)
- s.settle(200)
snap = s.snapshot()
check("borders are solid when nothing is being dragged",
@@ -290,7 +274,6 @@ def test_non_dragged_panes_get_a_dashed_border():
press(s, left["x"] + 4, left["y"])
motion(s, right["x"] + 5, right["y"] + 3)
- s.settle(120)
snap = s.snapshot()
top = snap.line(right["y"])[right["x"]:right["x"] + right["w"]]
dragged_top = snap.line(left["y"])[left["x"]:left["x"] + left["w"]]
@@ -300,7 +283,6 @@ def test_non_dragged_panes_get_a_dashed_border():
repr(dragged_top))
release(s, right["x"] + 5, right["y"] + 3)
- s.settle(200)
snap = s.snapshot()
for p in s.panes():
row = snap.line(p["y"])[p["x"]:p["x"] + p["w"]]
@@ -310,11 +292,9 @@ def test_non_dragged_panes_get_a_dashed_border():
def test_a_press_that_never_moves_dashes_nothing():
with Session(SH, cols=90, rows=16) as s:
- s.settle(200)
+ s.until_text("BLOCK")
left, right = split(s)
- s.settle(200)
press(s, left["x"] + 4, left["y"])
- s.settle(120)
snap = s.snapshot()
check("pressing without moving dashes nothing",
DASH_H not in snap.screen(), "")
@@ -324,12 +304,10 @@ def test_a_press_that_never_moves_dashes_nothing():
def test_the_hovered_target_is_still_highlighted_over_the_dashes():
with Session(SH, cols=90, rows=16) as s:
- s.settle(200)
+ s.until_text("BLOCK")
left, right = split(s)
- s.settle(200)
press(s, left["x"] + 4, left["y"])
motion(s, right["x"] + 5, right["y"] + 3)
- s.settle(120)
snap = s.snapshot()
run = snap.style_at(right["x"], right["y"])
check("the pane under the pointer keeps its drop highlight",
@@ -342,16 +320,14 @@ def test_the_hovered_target_is_still_highlighted_over_the_dashes():
def test_a_drag_that_ends_off_a_pane_still_clears():
"""The clear is derived, so even a drop into nowhere cannot leave it on."""
with Session(SH, cols=90, rows=16) as s:
- s.settle(200)
+ s.until_text("BLOCK")
left, right = split(s)
- s.settle(200)
press(s, left["x"] + 4, left["y"])
motion(s, right["x"] + 5, right["y"] + 3)
s.settle(120)
motion(s, 0, 0) # out into the margin
release(s, 0, 0)
- s.settle(200)
snap = s.snapshot()
for p in s.panes():
check(f"pane {p['id']} is un-greyed after a drop into nowhere",
@@ -361,15 +337,13 @@ def test_a_drag_that_ends_off_a_pane_still_clears():
def test_a_keystroke_mid_drag_clears_the_greying():
"""Any key ends a drag (so the mouse cannot wedge); colour must follow."""
with Session(SH, cols=90, rows=16) as s:
- s.settle(200)
+ s.until_text("BLOCK")
left, right = split(s)
- s.settle(200)
press(s, left["x"] + 4, left["y"])
motion(s, right["x"] + 5, right["y"] + 3)
s.settle(120)
s.send("x")
- s.settle(200)
snap = s.snapshot()
check("the greying goes with the drag it belonged to",
content_fg(snap, right) == GREEN, str(content_fg(snap, right)))
@@ -381,7 +355,7 @@ def test_config_shaders_reach_the_screen():
"""`shaders { ... }` is the surface these are actually driven from."""
conf = 'shaders {\n ruler amount=255 at=4 color="#ff0000"\n}\n'
with Session(SH, cols=60, rows=12, config=cfg(conf)) as s:
- s.settle(200)
+ s.until_text("BLOCK")
p = s.pane()
snap = s.snapshot()
on = snap.style_at(p["content_x"] + 4, p["content_y"])
@@ -409,7 +383,7 @@ def test_config_shaders_run_in_written_order():
def test_an_unknown_shader_is_refused_not_guessed():
with Session(SH, cols=50, rows=10,
config=cfg('shaders {\n bloom amount=255\n}\n')) as s:
- s.settle(200)
+ s.until_text("BLOCK")
check("an unknown shader leaves the session running and unshaded",
s.alive() and content_fg(s.snapshot(), s.pane()) == GREEN,
str(content_fg(s.snapshot(), s.pane())))
@@ -418,7 +392,7 @@ def test_an_unknown_shader_is_refused_not_guessed():
def test_positional_shaders_stay_off_the_chrome():
conf = 'shaders {\n vignette amount=255\n zebra amount=255 band=1\n}\n'
with Session(SH, cols=60, rows=12, config=cfg(conf)) as s:
- s.settle(200)
+ s.until_text("BLOCK")
p = s.pane()
snap = s.snapshot()
check("a full-strength positional stack still leaves the frame alone",
@@ -437,17 +411,14 @@ def lay(text):
def test_the_dragged_pane_can_have_a_state_of_its_own():
conf = 'states {\n dragging { tint amount=255 color="#0000ff" }\n}\n'
with Session(SH, cols=90, rows=16, config=cfg(conf)) as s:
- s.settle(200)
+ s.until_text("BLOCK")
left, right = split(s)
- s.settle(200)
press(s, left["x"] + 4, left["y"])
motion(s, right["x"] + 5, right["y"] + 3)
- s.settle(120)
snap = s.snapshot()
check("the pane in your hand takes the dragging state",
content_fg(snap, left) == "#0000ff", str(content_fg(snap, left)))
release(s, right["x"] + 5, right["y"] + 3)
- s.settle(200)
check("and gives it back on release",
content_fg(s.snapshot(), s.panes()[0]) == GREEN, "")
@@ -470,7 +441,6 @@ def test_the_hovered_pane_is_a_different_state_from_the_rest():
a, b, c = panes
press(s, a["x"] + 4, a["y"])
motion(s, b["x"] + 3, b["y"] + 3)
- s.settle(150)
snap = s.snapshot()
check("the pane under the pointer is the hover state",
content_fg(snap, b) == "#ff0000", str(content_fg(snap, b)))
@@ -515,7 +485,6 @@ def test_scrollback_is_a_state():
# wheel. SGR button 64 is wheel-up.
for _ in range(6):
s.send(rf"\e[<64;{p['content_x'] + 2};{p['content_y'] + 2}M")
- s.settle(250)
check("looking at the past is a state you can colour",
content_fg(s.snapshot(), p) == "#ffaa00",
str(content_fg(s.snapshot(), p)))
@@ -546,7 +515,7 @@ def test_exactly_one_state_wins():
def test_an_unknown_state_is_refused():
conf = 'states {\n haunted { dim amount=255 }\n}\n'
with Session(SH, cols=60, rows=12, config=cfg(conf)) as s:
- s.settle(200)
+ s.until_text("BLOCK")
check("an unknown state name leaves the session running",
s.alive() and content_fg(s.snapshot(), s.pane()) == GREEN,
str(content_fg(s.snapshot(), s.pane())))
diff --git a/tests/test_split_ux.py b/tests/test_split_ux.py
index faa1341..cfbd6e4 100644
--- a/tests/test_split_ux.py
+++ b/tests/test_split_ux.py
@@ -13,12 +13,18 @@ import time
from harness import Session, check, report
+FAST_TEXT = "hover_delay_ms 20\n"
+
+
def cfg(text):
f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
f.write(text)
f.close()
return f.name
+
+FAST = cfg(FAST_TEXT)
+
SH = ["/bin/sh", "-c", 'printf "\\033]2;shell\\007"; stty raw -echo; cat']
@@ -27,9 +33,13 @@ def hover(s, x, y):
def rest(s, x, y):
- """Hover and stay there: the guide arms on dwell, not on contact."""
+ """Hover and stay there: the guide arms on dwell, not on contact.
+
+ The wait is real elapsed time - the guide is gated on a clock, not on
+ anything observable - so the sessions that are not *about* the dwell run
+ with FAST_TEXT and pay 20ms instead of 250."""
hover(s, x, y)
- time.sleep(0.35)
+ time.sleep(0.06)
def click(s, x, y):
@@ -38,7 +48,7 @@ def click(s, x, y):
def test_no_more_plus():
- with Session(SH, cols=60, rows=14) as s:
+ with Session(SH, cols=60, rows=14, config=FAST) as s:
s.settle()
snap = s.snapshot()
p = s.pane()
@@ -57,7 +67,7 @@ def test_each_border_splits_toward_itself():
("bottom", lambda p: (p["x"] + 4, p["y"] + p["h"] - 1), "below"),
("top", lambda p: (p["x"] + 4, p["y"]), "above"),
]:
- with Session(SH, cols=80, rows=26) as s:
+ with Session(SH, cols=80, rows=26, config=FAST) as s:
s.settle()
before = s.pane()
x, y = cell(before)
@@ -90,7 +100,7 @@ def test_each_border_splits_toward_itself():
def test_the_guide():
- with Session(SH, cols=80, rows=26) as s:
+ with Session(SH, cols=80, rows=26, config=FAST) as s:
s.settle()
p = s.pane()
snap = s.snapshot()
@@ -127,7 +137,7 @@ def test_guide_follows_the_new_layout():
until the mouse moved and corrected it. It is derived during the paint
now, from the rects that paint registered.
"""
- with Session(SH, cols=120, rows=26) as s:
+ with Session(SH, cols=120, rows=26, config=FAST) as s:
s.settle()
p = s.pane()
edge_x, edge_y = p["x"] + p["w"] - 1, p["y"] + 3
@@ -155,7 +165,7 @@ def test_guide_follows_the_new_layout():
def test_the_guide_waits_for_a_rest():
"""A pointer crossing a border on its way elsewhere must not flash it."""
- with Session(SH, cols=80, rows=26) as s:
+ with Session(SH, cols=80, rows=26, config=FAST) as s:
s.settle()
p = s.pane()
left, right = p["x"], p["x"] + p["w"] - 1
@@ -215,7 +225,7 @@ def test_delay_is_configurable():
def test_guide_is_per_pane():
- with Session(SH, cols=150, rows=26) as s:
+ with Session(SH, cols=150, rows=26, config=FAST) as s:
s.settle()
s.key("\\\\")
s.settle()
@@ -231,7 +241,7 @@ def test_guide_is_per_pane():
def test_drag_still_moves():
"""The top border does double duty: click splits, drag moves."""
- with Session(SH, cols=90, rows=26) as s:
+ with Session(SH, cols=90, rows=26, config=FAST) as s:
s.settle()
s.key("\\\\")
s.settle()
@@ -257,7 +267,7 @@ def test_drag_still_moves():
def test_tiny_panes_have_no_targets():
- with Session(SH, cols=90, rows=16) as s:
+ with Session(SH, cols=90, rows=16, config=FAST) as s:
s.settle()
s.key("\\\\")
s.settle()
@@ -289,7 +299,7 @@ def edge_of(pane, side):
def test_a_split_below_the_floor_is_not_offered():
"""min_split is a usability floor, not the physical one: the pane below
would still *fit* by min_pane, and is refused anyway."""
- conf = cfg("min_pane cols=10 rows=3\nmin_split cols=40 rows=14\n")
+ conf = cfg(FAST_TEXT + "min_pane cols=10 rows=3\nmin_split cols=40 rows=14\n")
with Session(SH, cols=80, rows=26, config=conf) as s:
s.settle()
p = s.pane()
@@ -309,7 +319,7 @@ def test_a_split_below_the_floor_is_not_offered():
def test_the_floor_is_configurable_in_both_axes():
# Wide enough for columns, too short for rows.
- conf = cfg("min_split cols=20 rows=40\n")
+ conf = cfg(FAST_TEXT + "min_split cols=20 rows=40\n")
with Session(SH, cols=90, rows=26, config=conf) as s:
s.settle()
p = s.pane()
@@ -327,7 +337,7 @@ def test_the_floor_is_configurable_in_both_axes():
def test_the_keyboard_obeys_the_same_floor():
- conf = cfg("min_split cols=40 rows=14\n")
+ conf = cfg(FAST_TEXT + "min_split cols=40 rows=14\n")
with Session(SH, cols=80, rows=26, config=conf) as s:
s.settle()
s.key("\\\\")
@@ -343,7 +353,7 @@ def test_the_keyboard_obeys_the_same_floor():
def test_the_guide_says_which_way_it_goes():
- with Session(SH, cols=120, rows=26) as s:
+ with Session(SH, cols=120, rows=26, config=FAST) as s:
s.settle()
p = s.pane()
for side in ("l", "r", "t", "b"):
@@ -358,7 +368,7 @@ def test_the_guide_says_which_way_it_goes():
def test_the_arrow_sits_on_the_new_boundary():
- with Session(SH, cols=120, rows=26) as s:
+ with Session(SH, cols=120, rows=26, config=FAST) as s:
s.settle()
p = s.pane()
x, y = edge_of(p, "r")