diff --git a/tools/test_webui.py b/tools/test_webui.py index 53800cd..c3607a8 100644 --- a/tools/test_webui.py +++ b/tools/test_webui.py @@ -207,17 +207,14 @@ class TestFrontEndHoldMs(unittest.TestCase): def test_sends_hold_ms(self): self.assertIn("hold_ms", self.body) - def test_measures_press_duration_in_the_browser(self): - self.assertIn("performance.now()", self.body) - def test_does_not_send_separate_down_and_up_for_taps(self): """Two requests per key double the latency and break at 400 ms RTT.""" self.assertNotIn("send(key, 'down')", self.body) self.assertNotIn("send(key, 'up')", self.body) - def test_enforces_a_minimum_hold(self): - """A very fast click still has to clear the 20 ms debounce.""" - self.assertIn("MIN_HOLD_MS", self.body) + def test_uses_a_measured_tap_length(self): + """The tap length is a server-side constant, not a browser measurement.""" + self.assertIn("TAP_MS", self.body) class TestStreamUsesPump(unittest.TestCase): @@ -471,5 +468,43 @@ class TestStartsPoweredOff(unittest.TestCase): self.assertNotIn("supervisor.power_on()", src) +class TestOptimisticSend(unittest.TestCase): + """The request must leave on pointerdown, not on release. + + Waiting for pointerup spends the whole click duration with the network idle: + on a 400 ms link a 120 ms click cost 650 ms click-to-visible instead of 530 ms, + because nothing was in flight while the button was down. + """ + + def setUp(self): + _, http = make_app() + self.body = http.get("/").get_data(as_text=True) + + def test_sends_from_pointerdown(self): + # down() must issue the request itself rather than only recording a time. + down_fn = self.body.split("function down(key)")[1].split("function up(")[0] + self.assertIn("sendKey(", down_fn, + "down() must fire the request immediately") + + def test_up_does_not_send_the_press(self): + up_fn = self.body.split("function up(key)")[1].split("}")[0] + self.assertNotIn("sendKey(", up_fn, + "up() must not be where the press is sent") + + def test_long_press_is_still_reachable(self): + """Holding must still produce a held event, or long-press breaks.""" + self.assertIn("LONG_PRESS_MS", self.body) + self.assertIn("LONG_PRESS_AFTER_MS", self.body) + + def test_long_press_threshold_is_past_the_firmware_boundary(self): + """Must exceed 400 ms or the firmware will not call it held.""" + self.assertGreater(webui.LONG_PRESS_MS, 400) + self.assertGreaterEqual(webui.LONG_PRESS_AFTER_MS, 400) + + def test_optimistic_hold_is_a_short_press(self): + """The speculative press must stay under the held threshold.""" + self.assertLess(webui.TAP_MS, 400) + + if __name__ == "__main__": unittest.main() diff --git a/tools/webui.py b/tools/webui.py index 11f085d..b6eea5e 100644 --- a/tools/webui.py +++ b/tools/webui.py @@ -33,22 +33,40 @@ KEYPAD_PATH = "/machine/keypad" # key_debounce_10ms = 2 -> 20 ms to register a press # key_repeat_delay_10ms = 40 -> 400 ms counts as HELD, a different event # -# The browser sends the duration it measured and the server holds the key for -# exactly that long. It must not be reproduced by sending `down` and `up` as two -# requests: over a slow link the round trip between them *becomes* the press -# duration. Measured against this server at 400 ms RTT, an intended tap arrived as -# a 407 ms hold, so every short press was dispatched as a held key and handlers -# like MAIN_Key_MENU did nothing. Jitter either side of the threshold is what made -# it look intermittent rather than simply broken. -TAP_MS = 200 - -# Below the 20 ms debounce nothing registers at all, so even a very fast click has -# to ask for at least this long. -MIN_HOLD_MS = 60 +# The browser sends the duration it wants and the server holds the key for exactly +# that long. It must not be reproduced by sending `down` and `up` as two requests: +# over a slow link the round trip between them *becomes* the press duration. +# Measured against this server at 400 ms RTT, an intended tap arrived as a 407 ms +# hold, so every short press was dispatched as a held key and handlers like +# MAIN_Key_MENU did nothing. Jitter either side of the threshold is what made it +# look intermittent rather than simply broken. +# +# TAP_MS is latency the user pays directly, because the request does not return +# until the hold finishes. So it is measured, not guessed: with 12 trials per +# value, 20 ms registered only 5/12 times while 30 ms was 12/12. The nominal 20 ms +# debounce is not enough on its own -- KEYBOARD_Poll samples each column 8 times +# and wants 2 matching reads, so the real floor sits above it. 60 ms is double the +# proven floor, which keeps margin without paying the old 200 ms. +# +# An earlier 4-trial sweep called 30 ms reliable and would have shipped a flaky +# value; for timing questions, run enough trials to see the failures. +TAP_MS = 60 # A hold longer than this is a stuck key or a typo, not intent. MAX_HOLD_MS = 5000 +# Long-press support under optimistic send. +# +# The press is dispatched at pointerdown, before the browser knows how long you +# will hold, so the speculative request asks for a short TAP_MS press. If you keep +# holding past LONG_PRESS_AFTER_MS the browser sends a second, deliberately long +# press of LONG_PRESS_MS. +# +# LONG_PRESS_MS must clear the firmware's 400 ms held threshold +# (key_repeat_delay_10ms = 40) or the second press would land as another tap. +LONG_PRESS_AFTER_MS = 400 +LONG_PRESS_MS = 900 + BOUNDARY = "uvk5frame" TARGET_FPS = 15 @@ -318,8 +336,11 @@ def render_index(scale: int) -> str: