From ea0a7d912b2705d9e11cbf9e5f6a0c710e9f87f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Sun, 23 Aug 2026 23:20:41 +0200 Subject: [PATCH 01/20] feat: whole-project harness coverage; fix Steam+L3 chord wipe, stale click queue, spring zeta=1, trigger activity --- windows/tests/test_chords_media.py | 132 ++++++++++++++++ windows/tests/test_inputsrc_merge.py | 164 ++++++++++++++++++++ windows/tests/test_select_flow.py | 95 ++++++++++++ windows/tests/test_session_lifecycle.py | 186 +++++++++++++++++++++++ windows/tests/test_triggers_actuation.py | 38 +++++ windows/tests/test_utils_geometry.py | 77 ++++++++++ windows/tests/test_vptr.py | 46 ++++++ windows/triton/controller.py | 14 +- windows/triton/inputsrc.py | 11 +- windows/triton/utils.py | 6 +- 10 files changed, 764 insertions(+), 5 deletions(-) create mode 100644 windows/tests/test_chords_media.py create mode 100644 windows/tests/test_inputsrc_merge.py create mode 100644 windows/tests/test_select_flow.py create mode 100644 windows/tests/test_session_lifecycle.py create mode 100644 windows/tests/test_triggers_actuation.py create mode 100644 windows/tests/test_utils_geometry.py create mode 100644 windows/tests/test_vptr.py mode change 100755 => 100644 windows/triton/inputsrc.py mode change 100755 => 100644 windows/triton/utils.py diff --git a/windows/tests/test_chords_media.py b/windows/tests/test_chords_media.py new file mode 100644 index 0000000..c6f01b6 --- /dev/null +++ b/windows/tests/test_chords_media.py @@ -0,0 +1,132 @@ +"""Layer 2: chord matrix (Steam+L3 / Steam+VIEW / X-under-Steam / +position-cycle buttons), media stick roles and right-stick mouse move.""" + +from steamcontroller import SCButtons +from triton import state + + +def test_x_is_suppressed_while_steam_held(runner): + """Steam+X is the open chord: X must NOT delete while Steam is down.""" + state.set_cursor(0, 13) + runner.push(buttons=SCButtons.STEAM | SCButtons.X) + for _ in range(6): + runner.advance(0.05) + runner.push(buttons=SCButtons.STEAM | SCButtons.X) + runner.push(buttons=0) + names = [ev[1] for ev in runner.events() if ev[0] == "down"] + assert "KEY_BACKSPACE" not in names + + +def test_steam_l3_is_playpause_and_marks_chord_used(runner): + runner.push(buttons=SCButtons.STEAM | SCButtons.L3) + downs = [ev[1] for ev in runner.events() if ev[0] == "down"] + assert "KEY_PLAYPAUSE" in downs + assert "KEY_CAPSLOCK" not in downs # Steam reroutes L3 away from caps + for _ in range(24): + runner.advance(0.05) + runner.push(buttons=SCButtons.STEAM) + runner.push() + assert not state.should_close() # the chord marked Steam "used" + + +def test_plain_l3_is_caps_lock(runner): + runner.tap_button(SCButtons.L3) + downs = [ev[1] for ev in runner.events() if ev[0] == "down"] + assert "KEY_CAPSLOCK" in downs + + +def test_steam_view_is_alt_tab_chord(runner): + runner.push(buttons=SCButtons.STEAM | SCButtons.VIEW) + events = [(ev[0], ev[1]) for ev in runner.events()] + assert ("down", "KEY_LEFTALT") in events + assert ("down", "KEY_TAB") in events + assert ("up", "KEY_TAB") in events + assert ("up", "KEY_LEFTALT") not in events # Alt held for the hold + runner.push(buttons=SCButtons.STEAM) # release VIEW, keep Steam... + runner.push(buttons=0) # ...then drop Alt with Steam + events = [(ev[0], ev[1]) for ev in runner.events()] + assert ("up", "KEY_LEFTALT") in events + + +def test_view_alone_requests_position_cycle(runner): + runner.tap_button(SCButtons.VIEW) + assert state.take_position_cycle_request() + assert not state.take_position_cycle_request() # one shot per tap + + +def test_start_alone_requests_position_cycle(runner): + runner.tap_button(SCButtons.START) + assert state.take_position_cycle_request() + + +def test_media_track_skip_fires_once_per_deflection(runner): + btn = SCButtons.STEAM + + def count_song_events(): + return sum( + 1 + for ev in runner.events() + if ev[1] in ("KEY_PREVIOUSSONG", "KEY_NEXTSONG") + ) + + runner.push(lstick=(-20000, 0), buttons=btn) # LEFT zone edge + for _ in range(20): + runner.advance(0.03) + runner.push(lstick=(-20000, 0), buttons=btn) + first = count_song_events() + assert first >= 1 + for _ in range(20): # held: track skip never repeats + runner.advance(0.03) + runner.push(lstick=(-20000, 0), buttons=btn) + assert count_song_events() == first + + +def test_media_volume_ramps_while_held(runner): + btn = SCButtons.STEAM + runner.auto_advance = False + runner.advance(0.02) + runner.push(lstick=(0, -20000), buttons=btn) # edge: one step + def downs(): + return sum( + 1 + for ev in runner.events() + if ev[0] == "down" and ev[1] == "KEY_VOLUMEDOWN" + ) + + base = downs() + assert base == 1 + for _ in range(22): # ~0.66 s held: past STICK_HOLD_DELAY (0.5 s), ramp is 21 ms + runner.advance(0.03) + runner.push(lstick=(0, -20000), buttons=btn) + assert downs() > base + + +def test_right_stick_moves_system_mouse(runner): + import sc_runner + + for _ in range(12): + runner.push(rstick=(20000, 0)) + moves = [ + e for e in sc_runner.RecordingMouse.EVENTS if e[0] == "move" + ] + dxs = [e[1] for e in moves] + assert dxs and all(d > 0 for d in dxs) # rightward drift accumulates + + +def test_right_stick_deadzone_does_not_move_mouse(runner): + import sc_runner + + for _ in range(12): + runner.push(rstick=(3000, -3000)) # inside deadzone + assert not [ + e for e in sc_runner.RecordingMouse.EVENTS if e[0] == "move" + ] + + +def test_right_stick_y_axis_inverts(runner): + import sc_runner + + for _ in range(12): + runner.push(rstick=(0, 20000)) # stick up + dys = [e[2] for e in sc_runner.RecordingMouse.EVENTS if e[0] == "move"] + assert dys and all(d < 0 for d in dys) # screen y shrinks diff --git a/windows/tests/test_inputsrc_merge.py b/windows/tests/test_inputsrc_merge.py new file mode 100644 index 0000000..1d8b0fd --- /dev/null +++ b/windows/tests/test_inputsrc_merge.py @@ -0,0 +1,164 @@ +"""Layer 1: inputsrc frame merging + the InputMerger facade/haptic routing +(with fake sources — SteamHidSource itself needs hidapi hardware).""" + +import time + +from steamcontroller import SCButtons, SCStatus, SteamControllerInput +from triton import inputsrc + + +def _frame( + buttons=0, + lpad=(0, 0), + rpad=(0, 0), + lstick=(0, 0), + rstick=(0, 0), + **kw, +): + fields = dict( + status=SCStatus.INPUT, + seq=1, + buttons=buttons, + ltrig=0.0, + rtrig=0.0, + lpad_x=lpad[0], + lpad_y=lpad[1], + rpad_x=rpad[0], + rpad_y=rpad[1], + lstick_x=lstick[0], + lstick_y=lstick[1], + rstick_x=rstick[0], + rstick_y=rstick[1], + lpad_press=0.0, + rpad_press=0.0, + ) + fields.update(kw) + return SteamControllerInput(**fields) + + +def test_frame_has_activity_gates(): + assert not inputsrc._frame_has_activity(_frame()) + assert inputsrc._frame_has_activity(_frame(buttons=SCButtons.A)) + assert inputsrc._frame_has_activity(_frame(lstick_x=8001)) + assert inputsrc._frame_has_activity(_frame(lstick_y=-8001)) + assert inputsrc._frame_has_activity(_frame(rstick_x=20000)) + # Resting drift stays quiet. + assert not inputsrc._frame_has_activity(_frame(lstick_x=3000)) + + +def test_frame_has_activity_counts_analog_trigger_pull(): + """A half-pull with no digital bit is activity (haptics routing).""" + assert inputsrc._frame_has_activity(_frame(ltrig=1000)) + assert inputsrc._frame_has_activity(_frame(rtrig=16000)) + assert not inputsrc._frame_has_activity(_frame(ltrig=999)) + + +def test_merge_inputs_or_and_max(): + a = _frame( + buttons=SCButtons.A, ltrig=100, lpad=(10, 20), lstick=(500, 0) + ) + b = _frame( + buttons=SCButtons.B, + ltrig=300, + rtrig=50, + lpad=(-5, -6), + lstick=(-900, 0), + lpad_press=7.5, + ) + m = inputsrc.merge_inputs(a, b) + assert m.buttons == SCButtons.A | SCButtons.B + assert m.ltrig == 300 # max + assert m.rtrig == 50 + assert m.lpad_press == 7.5 + assert (m.lstick_x, m.lstick_y) == (-900, 0) # larger magnitude wins + + +def test_merge_inputs_touch_priority(): + # b touches the left pad while a doesn't -> b's pad coords win. + a = _frame(lpad=(111, 222)) + b = _frame( + buttons=SCButtons.LPADTOUCH, lpad=(-1, -2) + ) + m = inputsrc.merge_inputs(a, b) + assert (m.lpad_x, m.lpad_y) == (-1, -2) + # Both touching -> a wins ("a" is the tuned SC source by convention). + a2 = _frame(buttons=SCButtons.LPADTOUCH, lpad=(111, 222)) + m2 = inputsrc.merge_inputs(a2, b) + assert (m2.lpad_x, m2.lpad_y) == (111, 222) + + +class _FakeSrc: + def __init__(self, frames=None, fail_poll=False): + self.frames = list(frames or []) + self.calls: list = [] + self.fail_poll = fail_poll + + def poll(self): + if self.fail_poll: + raise RuntimeError("hid gone") + return self.frames.pop(0) if self.frames else None + + def set_lizard(self, on): + self.calls.append(("lizard", on)) + + def haptic_click(self): + self.calls.append(("click",)) + + def haptic_pad_click(self): + self.calls.append(("pad",)) + + def addExit(self): + self.calls.append(("exit",)) + + def close(self): + self.calls.append(("close",)) + + +def test_merger_polls_merges_and_routes_haptics(monkeypatch): + clock_t = 1000.0 + monkeypatch.setattr( + time, "monotonic", lambda: clock_t + ) + merger = inputsrc.InputMerger() + quiet = _FakeSrc() + loud = _FakeSrc(frames=[_frame(buttons=SCButtons.X)]) + merger.add(quiet) + merger.add(loud) + + merged = merger.poll() + assert merged is not None and merged.buttons & SCButtons.X + # Next poll: both sources dry -> None. + assert merger.poll() is None + + # The active source gets the tick; the quiet one does not. + merger.haptic_click() + merger.haptic_pad_click() + assert ("click",) in loud.calls + assert ("pad",) in loud.calls + assert quiet.calls == [] + + # Past the activity window the fan-out falls back to every source. + monkeypatch.setattr( + time, "monotonic", lambda: clock_t + 2.0 + ) + merger.haptic_click() + assert ("click",) in quiet.calls + + # Facade fan-out reaches all sources; addExit/close included. + merger.set_lizard(False) + merger.addExit() + merger.close() + assert ("lizard", False) in quiet.calls + assert ("exit",) in quiet.calls and ("close",) in quiet.calls + assert ("exit",) in loud.calls + + +def test_merger_survives_failing_source(capsys): + merger = inputsrc.InputMerger() + bad = _FakeSrc(fail_poll=True) + good = _FakeSrc(frames=[_frame(buttons=SCButtons.Y)]) + merger.add(bad) + merger.add(good) + merged = merger.poll() + assert merged is not None and merged.buttons & SCButtons.Y + assert "InputMerger" in capsys.readouterr().out diff --git a/windows/tests/test_select_flow.py b/windows/tests/test_select_flow.py new file mode 100644 index 0000000..0022ead --- /dev/null +++ b/windows/tests/test_select_flow.py @@ -0,0 +1,95 @@ +"""Layer 2: the Select-key text-selection session at SceneRunner level — +enter via pad click, drag arrows, freeze on finger-lift, roll-back +cancel on release, and clean teardown.""" + +from steamcontroller import SCButtons +from triton import state + + +def _select_pos(runner): + return runner.raw_at_cell("Select") + + +def _arrows(runner, name): + return [ + ev + for ev in runner.events() + if ev[0] == "down" and ev[1] == name + ] + + +def test_click_on_select_enters_select_mode(runner): + pos = _select_pos(runner) + runner.push(buttons=SCButtons.LB | SCButtons.LPADTOUCH, **pos) + assert state.is_select_active() + downs = [ev[1] for ev in runner.events() if ev[0] == "down"] + assert "KEY_LEFTSHIFT" in downs # OS shift held for the session + runner.push(buttons=0, **pos) + assert not state.is_select_active() + + +def test_select_drag_fires_arrows_1to1(runner): + pos = _select_pos(runner) + x = pos["lpad"][0] + runner.push(buttons=SCButtons.LB | SCButtons.LPADTOUCH, **pos) + # Drag right one SELECT_DRAG_STEP (0x1000) in 2 k frames. + for i in range(1, 5): + kw = {"lpad": (x + i * 0x400, 0)} + runner.push( + buttons=SCButtons.LB | SCButtons.LPADTOUCH, **kw + ) + rights = len(_arrows(runner, "KEY_RIGHT")) + assert rights == 1 # 0x1000 of travel exactly + for i in range(5, 9): # another full step + kw = {"lpad": (x + i * 0x400, 0)} + runner.push( + buttons=SCButtons.LB | SCButtons.LPADTOUCH, **kw + ) + assert len(_arrows(runner, "KEY_RIGHT")) == 2 + + +def test_select_freeze_when_finger_lifts(runner): + """Click button held but finger off the pad: selection freezes.""" + pos = _select_pos(runner) + runner.push(buttons=SCButtons.LB | SCButtons.LPADTOUCH, **pos) + before = len(_arrows(runner, "KEY_RIGHT")) + # Same raw coords but real_touch now False (no LPADTOUCH synthesized: + # with LB held TOUCH is synthesized only from the click button...). + runner.push(buttons=SCButtons.LB, lpad=pos["lpad"]) + runner.push( + buttons=SCButtons.LB, + lpad=(pos["lpad"][0] + 0x800, 0), + ) + assert len(_arrows(runner, "KEY_RIGHT")) == before + + +def test_select_reverse_inside_window_cancelled_on_release(runner): + """A reverse arrow fired just before lift-off is the natural roll-back + and gets cancelled with one compensating arrow.""" + + pos = _select_pos(runner) + x0 = pos["lpad"][0] + runner.push(buttons=SCButtons.LB | SCButtons.LPADTOUCH, **pos) + # Forward two steps slowly (deliberate travel). + for i in range(1, 9): + kw = {"lpad": (x0 + i * 0x400, 0)} + runner.push(buttons=SCButtons.LB | SCButtons.LPADTOUCH, **kw) + runner.clock.t += 0.03 + fwd = len(_arrows(runner, "KEY_RIGHT")) + assert fwd >= 2 + # Quick reverse of EXACTLY one step within the 0.12 s roll-back + # window (the anchor sits at x0 + 0x2000 after forward travel). + runner.push( + buttons=SCButtons.LB | SCButtons.LPADTOUCH, + lpad=(x0 + 0x1000, 0), + ) + revs_before = len(_arrows(runner, "KEY_LEFT")) + assert revs_before == 1 # fired immediately... + runner.push(buttons=0, lpad=(x0 + 0x1000, 0)) # lift right away + lefts_after = len(_arrows(runner, "KEY_LEFT")) + rights_after = len(_arrows(runner, "KEY_RIGHT")) + # The compensating RIGHT neutralises the accidental reverse: net + # selection travel equals the deliberate forward drag again. + assert rights_after - lefts_after == fwd + assert lefts_after == revs_before + assert not state.is_select_active() diff --git a/windows/tests/test_session_lifecycle.py b/windows/tests/test_session_lifecycle.py new file mode 100644 index 0000000..1c33232 --- /dev/null +++ b/windows/tests/test_session_lifecycle.py @@ -0,0 +1,186 @@ +"""Layer 2: session lifecycle — update() gating, the first-frame guard, +the open-close grace boundary, stale-queue cleanup on reopen, +release_held() teardown and feedback gating flags.""" + +import pytest +from steamcontroller import SCButtons +from triton import controller, state +from triton.geometry import CoordFraction +from triton.screen import set_dims +from triton.triton import drain_input_work + + +def _raw_frame(status): + from sc_runner import make_frame + + return make_frame(buttons=SCButtons.A)._replace(status=status) + + +def _seed_pointers(cstate): + from triton import state as _st + from triton.vptr import VirtualPointer + + center = CoordFraction.from_absolute(643, 184) + idle = VirtualPointer(_st.InputState.INACTIVE, center) + cstate.set_pointers( + idle, VirtualPointer(_st.InputState.INACTIVE, center) + ) + + +def test_update_ignores_non_input_status(runner): + # SCStatus only defines INPUT; any other status value is inert + other = 999 + before = runner.manager.sc_input_previous + controller.update(runner.src, _raw_frame(other), runner.manager) + assert runner.manager.sc_input_previous is before + assert not state.should_close() + + +def test_update_signals_exit_once_closed(runner): + state.close() + runner.src.exits = 0 + runner.push() # update() sees should_close -> addExit, skips input + assert runner.src.exits == 1 + + +def test_first_frame_only_guards_pads(runner): + """A fresh manager swallows frame 1 for pad handling (edge baseline); + from frame 2 on the pointer tracks the finger.""" + m = controller.ControllerManager(runner.cstate) + aim = runner.raw_at_cell("j") + # Frame 1: aimed at 'j', but the pad section is skipped. + controller.update( + runner.src, sc_make_frame(0, aim), m + ) + x, y = runner.cstate.get_pointers()[0].coord_frac.to_absolute() + seeded = CoordFraction.from_absolute(643, 184).to_absolute() + assert (round(x), round(y)) == (round(seeded[0]), round(seeded[1])) + # Frame 2: same aim now publishes. + controller.update(runner.src, sc_make_frame(0, aim), m) + px, py = runner.cell_center("j") + x, y = runner.cstate.get_pointers()[0].coord_frac.to_absolute() + assert (round(x), round(y)) == (px, py) + drain_input_work(runner.cstate, runner.virtual_kb) + + +def sc_make_frame(_buttons, kw): + from sc_runner import make_frame + + return make_frame(**kw) + + +def test_grace_boundary_is_strict(runner): + runner.auto_advance = False + open_t = runner.manager._open_t + # Advance to EXACTLY the boundary, then release Steam right on it. + runner.push(buttons=SCButtons.STEAM) # rising edge: chord unused + runner.advance(open_t + 1.0 - runner.clock.t) + runner.push(buttons=SCButtons.STEAM) + runner.push() # release at exactly +1.0 s + assert not state.should_close() + # The close only ever fires on a Steam FALLING edge past the grace, + # so a release that landed inside it needs one more press->release. + runner.advance(0.05) + runner.push(buttons=SCButtons.STEAM) + runner.advance(1.05) + runner.push(buttons=SCButtons.STEAM) + runner.push() + assert state.should_close() + + +def test_reopen_drains_stale_click_queue(): + """The click queue is class-shared; a fresh session must never replay + items a previous session left queued (phantom key on open).""" + stale = CoordFraction.from_absolute(100, 100) + controller.ControllerState.click_queue.append(stale) + try: + cstate = controller.ControllerState() + _seed_pointers(cstate) + manager = controller.ControllerManager(cstate) + assert list(cstate.click_queue) == [] + del manager + finally: + controller.ControllerState.click_queue.clear() + + +def test_release_held_drops_everything_os_side(runner): + import sc_runner + + m = runner.manager + + # Artificially engage every hold the manager can strand. + m._shift_active = True + m._enter_active = True + m._mouse_l_active = True # holds RIGHT button (swapped) + m._mouse_r_active = True # holds LEFT button (swapped) + m._select_pad = int(SCButtons.LT) + state.set_select_active(True) + m._deferred_base[int(SCButtons.LT)] = CoordFraction.from_absolute( + 10, 10 + ) + m._a_deferred_cell = (3, 1) + + m.release_held() + + ups = { + ev[1] for ev in sc_runner.RecordingKeyboard.EVENTS + if ev[0] == "up" + } + assert "KEY_LEFTSHIFT" in ups # shift + select teardown release it + assert "KEY_ENTER" in ups + mouse = [ + e for e in sc_runner.RecordingMouse.EVENTS if e[0] == "release" + ] + assert ("release", "right") in mouse + assert ("release", "left") in mouse + assert not m._shift_active and not m._enter_active + assert m._select_pad is None + assert not state.is_select_active() + assert not m._deferred_base and m._a_deferred_cell is None + + +def test_steam_running_silences_haptics_not_key_sound(runner): + state.set_steam_running(True) + state.set_cursor(*runner.cell_rc("a")) + runner.push(buttons=SCButtons.A) + runner.push() + assert runner.typed() == "a" # typing unaffected... + base_h, base_p = runner.haptics, runner.pad_haptics + state.haptic_tick() + state.pad_click_haptic() + assert runner.haptics == base_h # ...haptics silenced by Steam + assert runner.pad_haptics == base_p + assert runner.sounds >= 1 # key sound is NOT steam-gated + state.set_steam_running(False) + + +def test_rumble_and_sound_flags_gate_feedback(runner): + state.set_rumble_enabled(False) + base = runner.haptics + state.haptic_tick() + assert runner.haptics == base + + state.set_rumble_enabled(True) + state.set_key_sound_enabled(False) + state.set_cursor(*runner.cell_rc("l")) + runner.push(buttons=SCButtons.A) + runner.push() + assert runner.typed() == "l" + assert runner.sounds == 0 + + +@pytest.mark.parametrize( + "w,h", [(1286, 369), (1920, 540), (900, 300)] +) +def test_screen_dims_sync_geometry(w, h): + try: + set_dims(w, h) + from triton import geometry, screen + + assert geometry.width == w and geometry.height == h + cf = geometry.CoordFraction.from_absolute(w / 2, h / 2) + assert cf.x_fraction == pytest.approx(0.5) + assert cf.y_fraction == pytest.approx(0.5) + del screen + finally: + set_dims(1286, 369) diff --git a/windows/tests/test_triggers_actuation.py b/windows/tests/test_triggers_actuation.py new file mode 100644 index 0000000..d0ce4d3 --- /dev/null +++ b/windows/tests/test_triggers_actuation.py @@ -0,0 +1,38 @@ +"""Layer 1: analog trigger actuation (_TriggerMixin._osk_trigger_pressed) +— firmware full-pull bit vs the lowered sc_osk_trigger_actuation point.""" + +import pytest +from triton import state +from triton.triggers import _TriggerMixin + + +class _Host(_TriggerMixin): + pass + + +@pytest.fixture(autouse=True) +def reset_threshold(): + yield + state.set_sc_osk_trigger_threshold(None) + + +LT_BIT = 1 << 7 + + +def test_full_pull_bit_always_presses(): + h = _Host() + assert h._osk_trigger_pressed(LT_BIT | 0x0F00, LT_BIT, 0.0) + + +def test_no_threshold_ignores_analog(): + h = _Host() + state.set_sc_osk_trigger_threshold(None) + assert not h._osk_trigger_pressed(0, LT_BIT, 30000) + + +def test_low_actuation_engages_at_threshold(): + h = _Host() + state.set_sc_osk_trigger_threshold(8000) + assert not h._osk_trigger_pressed(0, LT_BIT, 7999) + assert h._osk_trigger_pressed(0, LT_BIT, 8000) + assert h._osk_trigger_pressed(0, LT_BIT, 32767) diff --git a/windows/tests/test_utils_geometry.py b/windows/tests/test_utils_geometry.py new file mode 100644 index 0000000..8c315a3 --- /dev/null +++ b/windows/tests/test_utils_geometry.py @@ -0,0 +1,77 @@ +"""Layer 1: pure math helpers (utils) and the CoordFraction value type.""" + +import pytest +from triton import utils +from triton.geometry import CoordFraction, set_dims + + +def test_clamp_orders_bounds(): + assert utils.clamp(5, 0, 10) == 5 + assert utils.clamp(-1, 0, 10) == 0 + assert utils.clamp(11, 0, 10) == 10 + assert utils.clamp(3, 3, 3) == 3 + + +def test_round_to_int_is_bankers(): + # Python's round() half-to-even — asserted so a future switch to + # round-half-up is a conscious change, not an accident. + assert utils.round_to_int(0.5) == 0 + assert utils.round_to_int(1.5) == 2 + assert utils.round_to_int(-0.5) == 0 + assert utils.round_to_int(2.4) == 2 + + +def test_compute_lowpass_endpoints_and_midpoint(): + assert utils.compute_lowpass(100, 0, 1.0) == 100 # identity at alpha=1 + assert utils.compute_lowpass(100, 0, 0.0) == 0 # frozen at alpha=0 + assert utils.compute_lowpass(100, 0, 0.5) == 50 + + +def test_ease_out_back_endpoints_clamped(): + assert utils.ease_out_back(-1.0) == pytest.approx(0.0, abs=1e-12) + assert utils.ease_out_back(0.0) == pytest.approx(0.0) + assert utils.ease_out_back(1.0) == pytest.approx(1.0) + assert utils.ease_out_back(2.0) == 1.0 # t clamped into [0, 1] + + +def test_ease_out_back_overshoots_then_lands(): + peak = max(utils.ease_out_back(t / 100) for t in range(101)) + assert peak > 1.0 # the springy landing overshoots... + + +def test_spring_p_settles_and_overshoots(): + assert utils.spring_p(-1.0, 0.7, 20.0) == 0.0 + assert utils.spring_p(0.0, 0.7, 20.0) == 0.0 + # Settled well before ~1 s. + assert abs(utils.spring_p(1.0, 0.7, 20.0) - 1.0) < 1e-6 + # Sub-critical damping overshoots once on the way up. + samples = [utils.spring_p(t / 200, 0.7, 20.0) for t in range(1, 200)] + assert max(samples) > 1.0 + # Monotone rise into the first crossing (no pre-overshoot jitter). + first_cross = next(i for i, v in enumerate(samples) if v >= 1.0) + rising = samples[: first_cross + 1] + assert rising == sorted(rising) + + +def test_spring_p_critical_damping_no_crash(): + """ζ=1.0 used to divide by √0; it must take the damped-limit form.""" + p = utils.spring_p(0.05, 1.0, 20.0) + assert 0.0 < p < 1.0 + assert abs(utils.spring_p(5.0, 1.0, 20.0) - 1.0) < 1e-9 + # Over-damped also fine. + assert 0.0 < utils.spring_p(0.05, 1.4, 20.0) < 1.0 + + +def test_coord_fraction_roundtrip_and_resize(): + try: + set_dims(1000, 500) + cf = CoordFraction.from_absolute(250, 125) + assert (cf.x_fraction, cf.y_fraction) == (0.25, 0.25) + assert cf.to_absolute() == (250.0, 125.0) + # Fractions survive a resize; pixels re-scale. + set_dims(2000, 500) + assert cf.to_absolute() == (500.0, 125.0) + cf.update_absolute(1000, 250) + assert (cf.x_fraction, cf.y_fraction) == (0.5, 0.5) + finally: + set_dims(1286, 369) diff --git a/windows/tests/test_vptr.py b/windows/tests/test_vptr.py new file mode 100644 index 0000000..bb69b9f --- /dev/null +++ b/windows/tests/test_vptr.py @@ -0,0 +1,46 @@ +"""Layer 1: VirtualPointer smoothing semantics (the pad-pointer low-pass).""" + + +from triton import state +from triton.geometry import CoordFraction +from triton.vptr import VirtualPointer + + +def _abs_eq(got, want): + x, y = got + assert (round(x), round(y)) == want + + +def _ptr(x, y): + return VirtualPointer( + state.InputState.HOVER, CoordFraction.from_absolute(x, y) + ) + + +def test_smoothen_alpha_one_is_identity(): + prev = _ptr(100, 100) + cur = _ptr(200, 300) + cur.smoothen(prev, 1.0) + _abs_eq(cur.coord_frac.to_absolute(), (200, 300)) + + +def test_smoothen_alpha_zero_holds_previous(): + prev = _ptr(100, 100) + cur = _ptr(200, 300) + cur.smoothen(prev, 0.0) + _abs_eq(cur.coord_frac.to_absolute(), (100, 100)) + + +def test_smoothen_glide_is_fraction_of_the_way(): + prev = _ptr(0, 0) + cur = _ptr(100, 200) + cur.smoothen(prev, 0.25) + _abs_eq(cur.coord_frac.to_absolute(), (25, 50)) + + +def test_smoothen_mutates_receiver_not_prev(): + prev = _ptr(10, 10) + cur = _ptr(110, 110) + cur.smoothen(prev, 0.5) + _abs_eq(prev.coord_frac.to_absolute(), (10, 10)) + _abs_eq(cur.coord_frac.to_absolute(), (60, 60)) diff --git a/windows/triton/controller.py b/windows/triton/controller.py index 993fa35..9ef1a93 100755 --- a/windows/triton/controller.py +++ b/windows/triton/controller.py @@ -147,6 +147,11 @@ class ControllerManager(_PadMixin, _StickMixin, _TriggerMixin): def __init__(self, controller_state): self.controller_state = controller_state + # The click queue is a CLASS attribute (shared by every + # ControllerState). Drain anything a previous OSK session left + # queued: replaying stale pad-click coordinates into a fresh + # session would type a phantom key on open. + controller_state.click_queue.clear() prev_ptrs = controller_state.get_pointers() self.prev_ptr_left = prev_ptrs[0] @@ -419,6 +424,13 @@ def handle_input(self, sc, sc_input): steam_now = bool( sc_input.buttons & (SCButtons.STEAM | SCButtons.QAM) ) # "..." (QAM) acts like Steam + # Rising Steam edge: this hold is a fresh chord, so nothing has + # "used" it yet. Cleared HERE (before the L3/VIEW/X handlers) so a + # chord action later this same frame can mark it used — otherwise + # e.g. Steam+L3's mark below would be wiped and releasing Steam + # would close the OSK mid-media-control. + if steam_now and not self._steam_was_pressed: + self._saw_x_during_steam = False # L3 → Caps Lock, unless Steam is held, in which case Steam + L3 is # Play/Pause. Manual rising-edge detection so the binding doesn't @@ -674,8 +686,6 @@ def handle_input(self, sc, sc_input): # Steam+X opens the keyboard; Steam pressed and released alone closes it. # (steam_now was computed at the top of this method.) x_now = bool(sc_input.buttons & SCButtons.X) - if steam_now and not self._steam_was_pressed: - self._saw_x_during_steam = False if steam_now and x_now and not self._saw_x_during_steam: self._saw_x_during_steam = True state.show() diff --git a/windows/triton/inputsrc.py b/windows/triton/inputsrc.py old mode 100755 new mode 100644 index 7ccc10e..d1cd87d --- a/windows/triton/inputsrc.py +++ b/windows/triton/inputsrc.py @@ -30,14 +30,21 @@ # Stick magnitude (of 32767) above which a frame counts as "actively in use". # Comfortably above resting drift (~3000) but below an intentional push. _ACTIVITY_STICK = 8000 +# Analog trigger pull (0..32767) that counts as activity — triggers rest at 0. +_ACTIVITY_TRIGGER = 1000 def _frame_has_activity(f): """True if this input frame shows the controller is actively being used - (any button/trigger, or a stick pushed past the deadzone). Used to decide - which controller 'owns' the current interaction so haptics go only to it.""" + (any button, an analog trigger pull, or a stick pushed past the + deadzone). Used to decide which controller 'owns' the current + interaction so haptics go only to it.""" if f.buttons: return True + if abs(getattr(f, "ltrig", 0) or 0) >= _ACTIVITY_TRIGGER: + return True + if abs(getattr(f, "rtrig", 0) or 0) >= _ACTIVITY_TRIGGER: + return True return ( abs(f.lstick_x) > _ACTIVITY_STICK or abs(f.lstick_y) > _ACTIVITY_STICK diff --git a/windows/triton/utils.py b/windows/triton/utils.py old mode 100755 new mode 100644 index 2c8f528..d8d4768 --- a/windows/triton/utils.py +++ b/windows/triton/utils.py @@ -31,8 +31,12 @@ def spring_p(t, zeta, omega0): if t <= 0.0: return 0.0 s = 1.0 - zeta * zeta - wd = omega0 * math.sqrt(s) e = math.exp(-zeta * omega0 * t) + if s <= 1e-9: + # Critically / over-damped limit (ζ→1): the sinusoid term collapses + # to 1 + ω0·t under the exponential — avoids the √0 division. + return 1.0 - e * (1.0 + omega0 * t) + wd = omega0 * math.sqrt(s) return 1.0 - e * ( math.cos(wd * t) + (zeta / math.sqrt(s)) * math.sin(wd * t) ) From f4f68ba36e72a2bd0bd74238f102329913007ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Sun, 23 Aug 2026 23:35:29 +0200 Subject: [PATCH 02/20] feat: cover skins/color, SteamHidSource, mouse hook, fonts; opt-in dummy-driver SDL render e2e --- windows/tests/test_color_skins.py | 109 +++++++++++++++++++++++ windows/tests/test_fonts.py | 37 ++++++++ windows/tests/test_hid_source.py | 126 +++++++++++++++++++++++++++ windows/tests/test_mousehook_proc.py | 91 +++++++++++++++++++ windows/tests/test_sdl_e2e.py | 65 ++++++++++++++ 5 files changed, 428 insertions(+) create mode 100644 windows/tests/test_color_skins.py create mode 100644 windows/tests/test_fonts.py create mode 100644 windows/tests/test_hid_source.py create mode 100644 windows/tests/test_mousehook_proc.py create mode 100644 windows/tests/test_sdl_e2e.py diff --git a/windows/tests/test_color_skins.py b/windows/tests/test_color_skins.py new file mode 100644 index 0000000..3174e11 --- /dev/null +++ b/windows/tests/test_color_skins.py @@ -0,0 +1,109 @@ +"""Layer 1: Color value type + skins palette machinery (pure parts).""" + +import pytest +from triton import color as tcolor +from triton import skins + + +def _rgb(c): + """(r, g, b) out of whatever the skins layer hands back.""" + if hasattr(c, "r"): + return (c.r, c.g, c.b) + r, g, b = c[0], c[1], c[2] + return (int(r), int(g), int(b)) + + +def test_color_roundtrip_and_equality(): + c = tcolor.Color(10, 20, 30) + assert tuple(c) == (10, 20, 30, 255) + assert c == tcolor.Color(*c) + assert tcolor.Color(1, 2, 3, 4) != tcolor.Color(1, 2, 3) + assert "Color(1, 2, 3" in repr(tcolor.Color(1, 2, 3)) + + +def test_color_ints_floats(): + assert tcolor.Color(12.9, 0.2, 255.5).r == 12 + + +def test_available_skins_include_bundled(): + names = skins.available_skins() + assert isinstance(names, list) and names + assert any("gruvbox" in n.lower() for n in names) + + +def test_skin_selection_roundtrip(): + names = skins.available_skins() + other = next( + n for n in names if n.lower() != skins.get_active_skin().lower() + ) + before = skins.get_generation() + skins.set_active_skin(other) + assert skins.get_active_skin() == other + assert skins.get_generation() == before + 1 + skins.set_active_skin(other) # same name: no bump + assert skins.get_generation() == before + 1 + skins.set_active_skin("Gruvbox") + + +def test_transparency_levels(): + for level in ("off", "low", "medium", "high"): + skins.set_transparency(level) + assert skins.get_transparency_scale() > 0.0 + skins.set_transparency("off") + assert not skins.is_transparent() + skins.set_transparency("high") + assert skins.is_transparent() + assert skins.get_transparency_scale() < 1.0 + + +def test_load_palette_bundled_skin(): + pal = skins.load_palette("Gruvbox") + assert isinstance(pal, dict) and pal + + def _ok(v): + return v is None or ( + hasattr(v, "r") and 0 <= v.r <= 255 and 0 <= v.a <= 255 + ) + + assert sum(1 for v in pal.values() if _ok(v)) >= 3 + + +def test_press_shade_is_visually_distinct(): + base = tcolor.Color(120, 120, 120) + shade = skins._press_shade(base, base) + assert skins._luminance(shade) != skins._luminance(base) + + +def test_blend_extremes(): + black = tcolor.Color(0, 0, 0) + target = tcolor.Color(200, 100, 50) + assert _rgb(skins._blend(black, target, 0.0)) == (0, 0, 0) + assert _rgb(skins._blend(black, target, 1.0)) == ( + 200, + 100, + 50, + ) + + +def test_clamp8_and_split_top_commas(): + assert skins._clamp8(-5) == 0 + assert skins._clamp8(300) == 255 + assert skins._clamp8(127.7) in (127, 128) + parts = skins._split_top_commas("rgb(1, 2, 3), #fff, x") + assert len(parts) == 3 and parts[1].strip() == "#fff" + + +@pytest.mark.parametrize( + "text", + ["#ff8000", "rgb(255,128,0)", "rgba(255, 128, 0, 1.0)"], +) +def test_parse_color_formats(text): + assert _rgb(skins._parse_color(text)) == (255, 128, 0) + + +def test_parse_color_short_hex_expands(): + assert _rgb(skins._parse_color("#f80")) == (255, 136, 0) + + +def test_parse_color_garbage_is_none(): + assert skins._parse_color("not-a-color") is None diff --git a/windows/tests/test_fonts.py b/windows/tests/test_fonts.py new file mode 100644 index 0000000..9337bc6 --- /dev/null +++ b/windows/tests/test_fonts.py @@ -0,0 +1,37 @@ +"""fonts: fallback lookup is pure; TTF rendering attempted only when the +headless SDL stack can init it (skipped otherwise).""" + +import os + +import pytest +from triton.fonts import _first_existing + + +def test_first_existing_returns_first_hit(tmp_path): + miss = tmp_path / "missing.ttf" + hit = tmp_path / "real.ttf" + hit.write_bytes(b"x") + assert _first_existing([str(miss), str(hit)]) == str(hit) + assert _first_existing([str(miss)]) is None + assert _first_existing([]) is None + + +def test_font_opens_bundled_font_when_ttf_available(): + from triton import resources + from triton.fonts import _Font + + font = resources.find_data_resource( + os.path.join("fonts", "Selawik-Semibold.ttf") + ) + if not font or not os.path.isfile(font): + pytest.skip("bundled font not present") + try: + import sdl3w as S + + S.TTF_Init() + f = _Font(font, 16) + except Exception as e: + pytest.skip(f"SDL_ttf unavailable headless: {e!r}") + col = type("C", (), {"r": 1, "g": 2, "b": 3})() + surf = f.render_surface("A", col) + assert surf is not None diff --git a/windows/tests/test_hid_source.py b/windows/tests/test_hid_source.py new file mode 100644 index 0000000..7074e13 --- /dev/null +++ b/windows/tests/test_hid_source.py @@ -0,0 +1,126 @@ +"""Layer 2: SteamHidSource lifecycle against a FAKE SteamController driver +(patched into inputsrc) — attach/poll/stale/facade-forwarding/teardown.""" + +import time + +import pytest +from sc_runner import make_frame +from triton import inputsrc, state + + +class FakeSC: + instances: list = [] + + def __init__(self, callback=None, restore_lizard_on_close=True): + self.cb = callback + self.restore = restore_lizard_on_close + self.calls: list = [] + self.exited = False + FakeSC.instances.append(self) + + def run(self): + self.calls.append("run") + while not self.exited and not state.should_close(): + time.sleep(0.001) + if self.restore and not state.is_steam_running(): + self.calls.append("restore-lizard") + + def set_lizard(self, on): + self.calls.append(("lizard", bool(on))) + + def haptic_click(self): + self.calls.append("hclick") + + def haptic_pad_click(self): + self.calls.append("hpad") + + def addExit(self): + self.exited = True + + +@pytest.fixture(autouse=True) +def patch_driver(monkeypatch): + FakeSC.instances = [] + monkeypatch.setattr(inputsrc, "SteamController", FakeSC) + yield + + +def _wait_live(src, timeout=3.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if src._live() is not None: + return src._live() + time.sleep(0.002) + raise AssertionError("driver never attached") + + +def test_poll_none_when_stale_or_absent(monkeypatch): + t = 1000.0 + monkeypatch.setattr(time, "monotonic", lambda: t) + src = inputsrc.SteamHidSource() + sc = _wait_live(src) + # No frame delivered yet (latest_t=0 -> stale) -> poll returns None. + assert src.poll() is None + sc.cb(None, make_frame(buttons=1)) + assert src.poll() is not None # fresh frame flows through + # Older than STALE_AFTER -> released device reads as absent. + t += inputsrc.SteamHidSource.STALE_AFTER + 0.1 + assert src.poll() is None + src.close() + + +def test_facade_forwards_to_live_device(monkeypatch): + t = 1000.0 + monkeypatch.setattr(time, "monotonic", lambda: t) + src = inputsrc.SteamHidSource() + sc = _wait_live(src) + src.set_lizard(False) + src.haptic_click() + src.haptic_pad_click() + assert ("lizard", False) in sc.calls + assert "hclick" in sc.calls and "hpad" in sc.calls + src.close() + assert sc.exited + + +def test_close_restores_lizard_on_steam_absence(monkeypatch): + state.set_steam_running(False) + t = 1000.0 + monkeypatch.setattr(time, "monotonic", lambda: t) + src = inputsrc.SteamHidSource() + sc = _wait_live(src) + src.close() + assert "restore-lizard" in sc.calls + + +def test_close_skips_lizard_restore_while_steam_runs(monkeypatch): + """Steam Input owns lizard mode then; restoring would cause the ~1 s + 'lizard blip' after closing the OSK.""" + state.set_steam_running(True) + try: + t = 1000.0 + monkeypatch.setattr(time, "monotonic", lambda: t) + src = inputsrc.SteamHidSource() + sc = _wait_live(src) + src.close() + assert "restore-lizard" not in sc.calls + finally: + state.set_steam_running(False) + + +def test_reconnect_after_device_drop(monkeypatch): + t = 1000.0 + monkeypatch.setattr(time, "monotonic", lambda: t) + src = inputsrc.SteamHidSource() + first = _wait_live(src) + n0 = len(FakeSC.instances) + first.addExit() # simulate the device dropping + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + _wait_live(src) + live = src._live() + if live is not first: + break + time.sleep(0.01) + assert len(FakeSC.instances) > n0 # a fresh driver instance appeared + src.close() diff --git a/windows/tests/test_mousehook_proc.py b/windows/tests/test_mousehook_proc.py new file mode 100644 index 0000000..5b102de --- /dev/null +++ b/windows/tests/test_mousehook_proc.py @@ -0,0 +1,91 @@ +"""Layer 2: the low-level mouse hook decision function with synthetic +events — injected vs real, OSK-owned moves, controller-correlation gate.""" + +import time + +import pytest +from triton import mousehook, state + + +class _FakeUser32: + def __init__(self): + self.next_calls = 0 + + def CallNextHookEx(self, *a): + self.next_calls += 1 + return 7 # sentinel "passed through" + + +@pytest.fixture(autouse=True) +def fake_hook(monkeypatch): + fake = _FakeUser32() + monkeypatch.setattr(mousehook, "_hook_user32", lambda: fake) + clock = {"t": 1000.0} + monkeypatch.setattr(time, "monotonic", lambda: clock["t"]) + yield fake, clock + + +def _event(injected=False): + ev = mousehook._MSLLHOOKSTRUCT() + ev.flags = 1 if injected else 0 # LLMHF_INJECTED + return ev + + +def _proc(fake, wParam, ev): + import ctypes + + ptr = ctypes.pointer(ev) + return mousehook._ll_mouse_proc(0, wParam, ptr) + + +def test_non_mouse_message_passes_through(fake_hook): + fake, _ = fake_hook + assert _proc(fake, 0x9999, _event()) == 7 + assert fake.next_calls == 1 + + +def test_osk_own_move_passes_within_injection_window(fake_hook): + fake, clock = fake_hook + state.set_last_controller_activity(clock["t"]) + state.set_osk_mouse_inject(clock["t"] - 0.05) # just injected + before = dict(mousehook._hook_stats) + assert _proc(fake, mousehook._WM_MOUSEMOVE, _event()) == 7 + # The exempt path must not count as swallowed/injected traffic. + assert mousehook._hook_stats["swallowed"] == before["swallowed"] + + +def test_injected_click_is_swallowed(fake_hook): + fake, clock = fake_hook + state.set_last_controller_activity(clock["t"]) + state.set_osk_mouse_inject(clock["t"] - 5.0) # injection stale + before = dict(mousehook._hook_stats) + assert ( + _proc( + fake, + mousehook._WM_MOUSEMOVE + 1, # a non-move (click) message + _event(injected=True), + ) + == 1 + ) + assert mousehook._hook_stats["injected"] == before["injected"] + 1 + assert mousehook._hook_stats["swallowed"] == before["swallowed"] + 1 + + +def test_steam_input_trailing_move_swallowed_by_correlation(fake_hook): + """Not flagged injected, but lands right after controller activity — + that's Steam Input's emulated mouse, not a real one.""" + fake, clock = fake_hook + state.set_last_controller_activity(clock["t"] - 0.02) + state.set_osk_mouse_inject(clock["t"] - 5.0) + before = dict(mousehook._hook_stats) + assert _proc(fake, mousehook._WM_MOUSEMOVE, _event()) == 1 + assert mousehook._hook_stats["recent"] == before["recent"] + 1 + + +def test_real_physical_mouse_passes(fake_hook): + fake, clock = fake_hook + state.set_last_controller_activity(clock["t"] - 99.0) + state.set_osk_mouse_inject(clock["t"] - 99.0) + before = dict(mousehook._hook_stats) + assert _proc(fake, mousehook._WM_MOUSEMOVE, _event()) == 7 + assert mousehook._hook_stats["swallowed"] == before["swallowed"] diff --git a/windows/tests/test_sdl_e2e.py b/windows/tests/test_sdl_e2e.py new file mode 100644 index 0000000..88675bd --- /dev/null +++ b/windows/tests/test_sdl_e2e.py @@ -0,0 +1,65 @@ +"""Layer 3 (opt-in): real Screen() + render under SDL's dummy video driver. + +Run with DUALTOUCH_E2E_SDL=1 — skipped by default so the fast headless +suite stays deterministic on machines/CI where the SDL DLL or dummy +driver is unavailable. Exercises window creation, skin load and a full +render pass with the production input pipeline feeding it. +""" + +import os +from contextlib import suppress + +import pytest + +pytestmark = pytest.mark.skipif( + os.environ.get("DUALTOUCH_E2E_SDL") != "1", + reason="opt-in: set DUALTOUCH_E2E_SDL=1", +) + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") + + +@pytest.fixture +def screen(): + import sdl3w as S + from triton.screen import Screen, set_dims + + with suppress(Exception): + S.TTF_Init() + set_dims(1286, 369) + scr = Screen() + yield scr + scr.destroy_textures() + del scr + + +def test_screen_constructs_and_renders_headlessly(screen, runner): + from steamcontroller import SCButtons + from triton import state + from triton.triton import drain_input_work + + state.set_virtual_kb(runner.virtual_kb) + screen.maybe_reload_skin() + + # Type through the REAL pipeline, then paint one frame. + state.set_cursor(*runner.cell_rc("a")) + runner.push(buttons=SCButtons.A) + runner.push() + drain_input_work(runner.cstate, runner.virtual_kb) + ptrs = runner.cstate.get_pointers() + screen.render_vkb(runner.virtual_kb, ptrs) + screen.clear() + + +def test_screen_survives_skin_reload_cycle(screen, runner): + from triton import skins + + skins.set_active_skin(skins.DEFAULT_SKIN) + from triton import state as _st + + kb = _st.get_virtual_kb() + ptrs = runner.cstate.get_pointers() + _st.set_virtual_kb(kb) + screen.maybe_reload_skin() + screen.content_changed(kb, ptrs) + screen.render(kb, ptrs) From 01d1e63a4bb2298c4c51ac739464146bb6bd0171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 08:16:36 +0200 Subject: [PATCH 03/20] feat: cover tray helpers/menu/battery, watchers, key sound, cursor ctrl+helper --- windows/tests/test_cursor_ctrl.py | 151 ++++++++++++++++++++++ windows/tests/test_cursor_helper.py | 107 ++++++++++++++++ windows/tests/test_key_sound.py | 126 +++++++++++++++++++ windows/tests/test_tray_helpers.py | 98 +++++++++++++++ windows/tests/test_tray_menu_battery.py | 159 ++++++++++++++++++++++++ windows/tests/test_watchers.py | 80 ++++++++++++ 6 files changed, 721 insertions(+) create mode 100644 windows/tests/test_cursor_ctrl.py create mode 100644 windows/tests/test_cursor_helper.py create mode 100644 windows/tests/test_key_sound.py create mode 100644 windows/tests/test_tray_helpers.py create mode 100644 windows/tests/test_tray_menu_battery.py create mode 100644 windows/tests/test_watchers.py diff --git a/windows/tests/test_cursor_ctrl.py b/windows/tests/test_cursor_ctrl.py new file mode 100644 index 0000000..6dfccde --- /dev/null +++ b/windows/tests/test_cursor_ctrl.py @@ -0,0 +1,151 @@ +"""Layer 1/2: the cursor-hide control plane — cursor_ctrl marker writing +plus cursor_helper's parsing/trust logic (no real cursors touched).""" + +import os +import sys + +import cursor_ctrl +import pytest + + +@pytest.fixture(autouse=True) +def _data_dir(tmp_path, monkeypatch): + monkeypatch.setattr(cursor_ctrl, "user_data_dir", lambda: str(tmp_path)) + monkeypatch.setattr(cursor_ctrl, "_osk_cursor_hidden", False) + return tmp_path + + +def _marker(tmp_path): + return tmp_path / "cursor_action.txt" + + +def test_marker_path_uses_user_data_dir(_data_dir): + p = cursor_ctrl._marker_path() + assert str(_data_dir) in str(p) and p.endswith("cursor_action.txt") + + +def test_set_osk_cursor_visible_writes_tokened_marker(_data_dir): + cursor_ctrl.set_osk_cursor_visible(False) # hide + text = _marker(_data_dir).read_text() + mode, pid, token = text.split("|") + assert mode == "hide" + assert int(pid) == os.getpid() + assert token == cursor_ctrl._TOKEN + assert cursor_ctrl._osk_cursor_hidden is True + + # Same state again: no new write. + _marker(_data_dir).unlink() + cursor_ctrl.set_osk_cursor_visible(False) + assert not _marker(_data_dir).exists() + + # Show flips it back. + cursor_ctrl.set_osk_cursor_visible(True) + assert _marker(_data_dir).read_text().startswith("show|") + assert cursor_ctrl._osk_cursor_hidden is False + + +def test_flag_not_flipped_on_failed_write(_data_dir, monkeypatch): + # Point the marker at a directory that does not exist -> write fails. + monkeypatch.setattr( + cursor_ctrl, + "user_data_dir", + lambda: str(_data_dir / "missing" / "dir"), + ) + cursor_ctrl.set_osk_cursor_visible(False) + assert cursor_ctrl._osk_cursor_hidden is False + + +def test_force_restore_cursor_shows(_data_dir): + cursor_ctrl._osk_cursor_hidden = True + cursor_ctrl.force_restore_cursor() + assert _marker(_data_dir).read_text().startswith("show|") + assert cursor_ctrl._osk_cursor_hidden is False + + +def test_install_helper_schedules_task_and_kicks_daemon( + _data_dir, monkeypatch +): + runs = [] + + def fake_run(cmd, **kw): + runs.append(cmd) + + class R: + returncode = 0 + stderr = "" + + return R() + + monkeypatch.setattr(cursor_ctrl.subprocess, "run", fake_run) + monkeypatch.delattr(cursor_ctrl.shutil, "copyfile", raising=False) + # Not frozen: source path uses sys.executable + script path. + assert cursor_ctrl.install_helper() is True + create = [c for c in runs if "/Create" in c] + run_cmd = [c for c in runs if "/Run" in c] + assert len(create) == 1 and len(run_cmd) == 1 + cmd = create[0] + assert cmd[cmd.index("/TN") + 1] == "DualTouchCursor" + tr = cmd[cmd.index("/TR") + 1] + if sys.frozen if hasattr(sys, "frozen") else False: + assert "--cursor-helper --daemon --token" in tr + else: + assert "cursor_helper.py\" --daemon --token" in tr + assert cursor_ctrl._TOKEN in tr + # The show marker is written so the daemon's first poll has work. + assert _marker(_data_dir).exists() + + +def test_install_helper_reports_failure(_data_dir, monkeypatch): + def fail(cmd, **kw): + class R: + returncode = 1 + stderr = "denied" + + return R() + + monkeypatch.setattr(cursor_ctrl.subprocess, "run", fail) + assert cursor_ctrl.install_helper() is False + + +def _with_frozen_helper(monkeypatch, tmp_path, same_size=True, version=None): + """Stage a frozen-exe scenario around _frozen_helper_exe.""" + me = tmp_path / "DualTouch-windows.exe" + me.write_bytes(b"MZ-fake") + other = tmp_path / "DualTouch-cursor-helper.exe" + + if version is None: + other.write_bytes(b"X" * (len(b"MZ-fake") if same_size else 3)) + else: + import struct + + data = struct.pack(" recopy from me. + result = cursor_ctrl._frozen_helper_exe() + if result is not None: # copyfile of a fake exe still succeeds + assert result == str(other) + assert other.read_bytes() == b"MZ-fake" + + +def test_product_version_of_garbage_file_is_none(tmp_path): + f = tmp_path / "no-version.exe" + f.write_bytes(b"not an exe") + assert cursor_ctrl._product_version(str(f)) is None + assert cursor_ctrl._product_version(str(tmp_path / "missing")) is None diff --git a/windows/tests/test_cursor_helper.py b/windows/tests/test_cursor_helper.py new file mode 100644 index 0000000..94b79cf --- /dev/null +++ b/windows/tests/test_cursor_helper.py @@ -0,0 +1,107 @@ +"""Layer 1/2: cursor_helper — marker parsing, token handling, trust set +and main() exit codes (win32 cursor internals are not touched).""" + +import sys + +import cursor_helper + + +def test_load_token_from_args(): + # Sets the module-global token; returns nothing. + cursor_helper._load_token_from_args( + ["prog", "--daemon", "--token", "abc123"] + ) + assert cursor_helper._TOKEN == "abc123" + # Missing value / absent flag -> None. + cursor_helper._load_token_from_args(["p", "--token"]) + assert cursor_helper._TOKEN is None + cursor_helper._load_token_from_args(["p"]) + assert cursor_helper._TOKEN is None + cursor_helper._load_token_from_args(["p", "--token", " tok "]) + assert cursor_helper._TOKEN == "tok" + + +def test_parse_marker_matrix(): + ok = cursor_helper._parse_marker("hide|4242|sekrit", "sekrit") + assert ok == ("hide", 4242) + assert cursor_helper._parse_marker("show|1|x", "y") is None # token + assert cursor_helper._parse_marker("hide|4242|sekrit", None) is None + assert cursor_helper._parse_marker("", "t") is None + assert cursor_helper._parse_marker(None, "t") is None + assert cursor_helper._parse_marker("hide|4242", "t") is None + assert cursor_helper._parse_marker("hide|4242|a|b", "a") is None + assert cursor_helper._parse_marker("sleep|4242|t", "t") is None + assert cursor_helper._parse_marker("hide|notapint|t", "t") is None + assert cursor_helper._parse_marker("hide|0|t", "t") is None # pid<=1 + assert cursor_helper._parse_marker("hide|-3|t", "t") is None + + +def test_allowed_image_names_frozen_vs_source(monkeypatch): + names = cursor_helper._allowed_image_names(frozen=True) + lowered = {n for n in names} + assert "dualtouch-cursor-helper.exe" in lowered + assert "dualtouch-windows.exe" in lowered + monkeypatch.delattr(sys, "frozen", raising=False) + src = cursor_helper._allowed_image_names(frozen=False) + assert src == {sys.executable.lower().rsplit("\\", 1)[-1]} + + +class _FakePsutil: + @staticmethod + def Process(_pid): + raise _Boom("no such process") + + +def test_pid_is_trusted_rejects_junk(monkeypatch): + import types + + monkeypatch.setitem( + sys.modules, "psutil", types.ModuleType("psutil_fake") + ) + monkeypatch.setitem(sys.modules, "psutil", _FakePsutil) + assert not cursor_helper._pid_is_trusted(0) + assert not cursor_helper._pid_is_trusted(-5) + assert not cursor_helper._pid_is_trusted(99999) # any error -> False + + +class _Boom(Exception): + pass + + +def _boom(_pid): + raise _Boom("no such process") + + +def test_read_marker_strips_bom(tmp_path, monkeypatch): + marker = tmp_path / "cursor_action.txt" + marker.write_text("show|1|t", encoding="utf-8") + monkeypatch.setattr(cursor_helper, "_MARKER", str(marker)) + assert cursor_helper._read_marker() == "show|1|t" + marker.unlink() + assert cursor_helper._read_marker() is None + + +def test_main_one_shot_paths(tmp_path, monkeypatch, capsys): + marker = tmp_path / "cursor_action.txt" + monkeypatch.setattr(cursor_helper, "_MARKER", str(marker)) + monkeypatch.setattr(sys, "argv", ["helper"]) + + assert cursor_helper.main() == 1 # no --token at all + + monkeypatch.setattr(sys, "argv", ["helper", "--token", "t"]) + assert cursor_helper.main() == 0 # no marker: nothing to do + + marker.write_text("garbage") + assert cursor_helper.main() == 1 # unparseable marker + + marker.write_text("hide|999999|wrong-token") + assert cursor_helper.main() == 1 # bad token refused + + +def test_daemon_loop_fails_closed_without_token(monkeypatch): + shown = [] + monkeypatch.setattr(cursor_helper, "_show", lambda *a: shown.append(1)) + monkeypatch.setattr(cursor_helper, "_TOKEN", None) + rc = cursor_helper._daemon_loop() + assert rc == 1 + assert shown == [1] # cursor restored before bailing out diff --git a/windows/tests/test_key_sound.py b/windows/tests/test_key_sound.py new file mode 100644 index 0000000..deed5b7 --- /dev/null +++ b/windows/tests/test_key_sound.py @@ -0,0 +1,126 @@ +"""Layer 1: key_sound resolution, playback and the click debounce.""" + +import key_sound +import pytest + + +@pytest.fixture(autouse=True) +def _reset_caches(monkeypatch): + monkeypatch.setattr(key_sound, "_path_cache", {}) + monkeypatch.setattr(key_sound, "_paths_resolved", False) + monkeypatch.setattr(key_sound, "_last_key_click", 0.0) + + +@pytest.fixture +def played(monkeypatch): + calls = [] + + def fake_play(sound, flags): + calls.append((sound, flags)) + + monkeypatch.setattr(key_sound.winsound, "PlaySound", fake_play) + return calls + + +class _Clock: + t = 1000.0 + + def __call__(self): + return self.t + + +@pytest.fixture +def clock(monkeypatch): + c = _Clock() + monkeypatch.setattr(key_sound.time, "monotonic", c) + return c + + +def test_play_uses_resolved_steam_path(played, monkeypatch, tmp_path, clock): + snd = tmp_path / "steamui" / "sounds" + snd.mkdir(parents=True) + (snd / "key.wav").write_bytes(b"RIFF") + monkeypatch.setattr( + key_sound, + "_SOUNDS", + {"key": "steamui/sounds/key.wav"}, + raising=False, + ) + monkeypatch.setattr( + key_sound, + "find_steam_path", + lambda: str(tmp_path), + raising=False, + ) + key_sound.play_key_sound() + assert len(played) == 1 + path, flags = played[0] + assert "steamui" in str(path).lower() and str(path).endswith(".wav") + assert flags & ( + key_sound.winsound.SND_FILENAME | key_sound.winsound.SND_ASYNC + ) + # Cached: a second call must not re-run find_steam_path. + n = {"calls": 0} + + def counting(): + n["calls"] += 1 + return str(tmp_path) + + monkeypatch.setattr(key_sound, "find_steam_path", counting, False) + clock.t += 0.1 # past the debounce + key_sound.play_key_sound() + assert n["calls"] == 0 # served from cache + assert len(played) == 2 + + +def test_no_steam_install_is_silent_and_cached(played, monkeypatch): + monkeypatch.setattr( + key_sound, "find_steam_path", lambda: None, raising=False + ) + key_sound.play_key_sound() + key_sound.play_open_sound() + assert played == [] + assert key_sound._path_cache["key"] is None + assert key_sound._paths_resolved is True + + +def test_click_debounce_suppresses_rapid_repeats(played, clock, monkeypatch): + monkeypatch.setattr( + key_sound, + "_path_cache", + {"key": "C:\\x\\key.wav"}, + raising=False, + ) + monkeypatch.setattr(key_sound, "_paths_resolved", True) + key_sound.play_key_sound() + clock.t += 0.01 # under the 25 ms debounce window + key_sound.play_key_sound() + assert len(played) == 1 + clock.t += 0.05 # past it: fires again + key_sound.play_key_sound() + assert len(played) == 2 + + +def test_open_close_sounds_are_not_debounced(played, clock, monkeypatch): + monkeypatch.setattr(key_sound, "_paths_resolved", True) + monkeypatch.setattr( + key_sound, + "_path_cache", + {"open": "C:\\x\\open.wav", "close": "C:\\x\\close.wav"}, + raising=False, + ) + key_sound.play_open_sound() + key_sound.play_open_sound() # same instant: still both fire + key_sound.play_close_sound() + names = [str(p) for p, _ in played] + assert sum("open" in n for n in names) == 2 + assert sum("close" in n for n in names) == 1 + + +def test_resolver_exception_is_swallowed(played, monkeypatch): + def boom(): + raise RuntimeError("no registry") + + monkeypatch.setattr(key_sound, "find_steam_path", boom, False) + key_sound.play_key_sound() # must not raise + assert played == [] diff --git a/windows/tests/test_tray_helpers.py b/windows/tests/test_tray_helpers.py new file mode 100644 index 0000000..9184d0f --- /dev/null +++ b/windows/tests/test_tray_helpers.py @@ -0,0 +1,98 @@ +"""Layer 1/2: tray single-instance mutex + helper shims.""" + +import sys + +import autostart +import pytest +from tray import helpers + + +def test_tray_mutex_name_shape(): + name = helpers._tray_mutex_name() + assert "DualTouch_Tray_SingleInstance" in name + assert name.startswith("Local\\") or "\\" in name + + +def test_user_only_dacl_builds_or_falls_back(): + # Real advapi32 path on CI/dev boxes; either outcome is valid, the + # function must simply not blow up and return bytes-or-None. + dacl = helpers._user_only_dacl() + assert dacl is None or len(dacl) > 0 # ACL buffer or graceful fallback + + +def test_mutex_create_conflict_and_probe(): + handle = helpers._create_tray_mutex() + if handle is None: + pytest.skip("mutex unavailable in this environment") + try: + # A second create must detect the existing instance. + assert helpers._create_tray_mutex() is None + assert helpers._tray_mutex_held() is True + finally: + import ctypes + + ctypes.windll.kernel32.CloseHandle(handle) + assert helpers._tray_mutex_held() is False + + +def test_steam_running_false_without_psutil(monkeypatch): + real_import = __import__ + + def fake_import(name, *a, **kw): + if name == "psutil": + raise ImportError("gone") + return real_import(name, *a, **kw) + + monkeypatch.setitem(sys.modules, "psutil", None) + monkeypatch.setattr("builtins.__import__", fake_import) + assert helpers._steam_running() is False + + +def test_steam_running_detects_process(monkeypatch): + class _FakeIter: + def __init__(self, info): + self.info = info + + def __iter__(self): + return iter(self.info) + + class _FakePsutil: + NoSuchProcess = Exception + AccessDenied = Exception + + @staticmethod + def process_iter(attrs=None): + class _Proc: + def __init__(self, name): + self.info = {"name": name} + + return [_Proc("notepad.exe"), _Proc("steam.exe")] + + monkeypatch.setitem(sys.modules, "psutil", _FakePsutil) + monkeypatch.setattr(helpers, "STEAM_PROC_NAME", "steam.exe") + assert helpers._steam_running() is True + + +def test_apply_autostart_delegates(monkeypatch): + seen = [] + monkeypatch.setattr( + autostart, "set_enabled", lambda v: seen.append(bool(v)) + ) + helpers._apply_autostart(True) + helpers._apply_autostart(0) + assert seen == [True, False] + + +def test_relaunch_elevated_reports_failure(monkeypatch): + def fail(*a, **kw): + raise OSError("cancelled") + + monkeypatch.setattr(helpers.ctypes, "windll", fail.__self__ if False else helpers.ctypes.windll) + # ShellExecuteW raising (user cancels UAC) -> False, no crash. + monkeypatch.setattr( + helpers.ctypes.windll.shell32, + "ShellExecuteW", + fail, + raising=False, + ) + assert helpers._relaunch_elevated() is False diff --git a/windows/tests/test_tray_menu_battery.py b/windows/tests/test_tray_menu_battery.py new file mode 100644 index 0000000..88f4a8f --- /dev/null +++ b/windows/tests/test_tray_menu_battery.py @@ -0,0 +1,159 @@ +"""Layer 1/2: tray battery notification matrix + menu wiring smoke.""" + +from threading import Event +from unittest.mock import MagicMock + +import pytest +from tray.battery import _BatteryMixin +from triton import state as triton_state + + +class _Batt: + def __init__(self, percent, charging, charge_complete=False): + self.percent = percent + self.charging = charging + self.charge_complete = charge_complete + + +class _Host(_BatteryMixin): + def __init__(self): + self._stop_event = Event() + self._battery = None + self._battery_label = None + self._icon_ref = None + self._current_sc = None + self._notify_calls = [] + self._refreshed = 0 + self._was_charging = False + self._low_warned_at = None + self._charge_complete_notified = False + + def _notify(self, msg, title=None): + self._notify_calls.append(msg) + + def _refresh_menu(self): + self._refreshed += 1 + + +@pytest.fixture(autouse=True) +def _rumble_on(): + triton_state.set_rumble_enabled(True) + yield + triton_state.set_rumble_enabled(False) + + +def _no_puck(monkeypatch): + import steamcontroller + + monkeypatch.setattr( + steamcontroller, "present_product_ids", lambda: set(), False + ) + + +def test_update_battery_ui_labels_and_refresh(): + h = _Host() + for batt, want in [ + (_Batt(100, False, True), "100% (charged)"), + (_Batt(55, True), "55% (charging)"), + (_Batt(23, False), "23%"), + ]: + h._update_battery_ui(batt) + assert h._battery_label == f"Steam Controller: {want}" + assert h._refreshed >= 3 + h._battery = batt # the polling thread sets this on real frames + assert h.is_battery_known(None) is True # pystray item arg ignored + + +def test_low_battery_bands_escalate_once(monkeypatch): + _no_puck(monkeypatch) + h = _Host() + h._battery_notifications(_Batt(40, False)) # arm baseline (>35) + assert h._notify_calls == [] + h._battery_notifications(_Batt(25, False)) # entering 30 band warns + n_first = len(h._notify_calls) + assert n_first >= 1 + # Same band again: no duplicate. + h._battery_notifications(_Batt(22, False)) + assert len(h._notify_calls) == n_first + # Deeper bands escalate (10, then critical <=5). + h._battery_notifications(_Batt(8, False)) + assert len(h._notify_calls) > n_first + n_crit = len(h._notify_calls) + h._battery_notifications(_Batt(4, False)) + assert len(h._notify_calls) > n_crit + + +def test_recovery_resets_latch(monkeypatch): + _no_puck(monkeypatch) + h = _Host() + h._battery_notifications(_Batt(20, False)) + n1 = len(h._notify_calls) + assert n1 >= 1 + h._battery_notifications(_Batt(50, False)) # above recover threshold + h._battery_notifications(_Batt(20, False)) # same band warns again + assert len(h._notify_calls) > n1 + + +def test_charging_never_warns_and_puck_suppresses_charged_toast( + monkeypatch, +): + """Controller on the USB dongle's wired pass-through ('the puck'): + charging there is docking, not charging a battery — stay silent.""" + import steamcontroller as sc + + wired = sc.PRODUCT_ID_WIRED + monkeypatch.setattr( + sc, "present_product_ids", lambda: {wired}, False + ) + h = _Host() + h._battery_notifications(_Batt(10, True)) + h._battery_notifications(_Batt(12, True)) + assert h._notify_calls == [] + + +def test_unplug_toasts_once(monkeypatch): + _no_puck(monkeypatch) + h = _Host() + h._update_battery_ui(_Batt(60, True)) # seed _was_charging state + h._battery_notifications(_Batt(60, True)) + n = len(h._notify_calls) + h._battery_notifications(_Batt(58, False)) # unplug edge + assert len(h._notify_calls) == n + 1 + h._battery_notifications(_Batt(57, False)) + assert len(h._notify_calls) == n + 1 # not repeated + + +def test_charge_complete_toasts_once(monkeypatch): + _no_puck(monkeypatch) + h = _Host() + h._battery_notifications(_Batt(99, True)) + h._battery_notifications(_Batt(100, False)) + n = len(h._notify_calls) + assert n >= 1 + h._battery_notifications(_Batt(100, False)) + assert len(h._notify_calls) == n + + +def test_haptic_on_low_warning_when_controller_present(): + h = _Host() + + class _SC: + def __init__(self): + self.clicks = 0 + + def haptic_click(self): + self.clicks += 1 + + sc = _SC() + h._current_sc = sc + h._battery_notifications(_Batt(15, False)) + assert sc.clicks >= 1 + + +def test_menu_builds_with_stub_app(): + from tray.menu import build_menu + + app = MagicMock() + app.diacritic_locale_options.return_value = ["en", "de"] + menu = build_menu(app) + assert menu is not None diff --git a/windows/tests/test_watchers.py b/windows/tests/test_watchers.py new file mode 100644 index 0000000..c4b4bb8 --- /dev/null +++ b/windows/tests/test_watchers.py @@ -0,0 +1,80 @@ +"""Layer 2: tray-side watcher (_Watcher) — the OSK-closed chord listener.""" + +import watchers +from steamcontroller import SCButtons + + +class _FakeSC: + def __init__(self): + self.exits = 0 + + def addExit(self): + self.exits += 1 + + +def _frame(buttons, status=66): + from sc_runner import make_frame as _mf + + return _mf(buttons=buttons)._replace(status=status) + + +def test_non_input_frame_ignored(): + w = watchers._Watcher(lambda: False) + sc = _FakeSC() + w.on_input(sc, _frame(SCButtons.STEAM | SCButtons.X, status=0)) + assert not w.triggered and sc.exits == 0 + + +def test_abort_releases_chord_and_exits(): + w = watchers._Watcher(lambda: True) + sc = _FakeSC() + w._chord.alt_held = True + w.on_input(sc, _frame(0)) + assert not w._chord.alt_held + assert sc.exits == 1 + assert not w.triggered + + +def test_steam_plus_open_chord_triggers(): + w = watchers._Watcher(lambda: False) + sc = _FakeSC() + w.on_input(sc, _frame(SCButtons.X)) # chord alone: nothing + assert not w.triggered and sc.exits == 0 + w.on_input(sc, _frame(0)) # release so the next press is a rising edge + w.on_input(sc, _frame(SCButtons.STEAM | SCButtons.X)) + assert w.triggered and sc.exits == 1 + + +def test_qam_counts_as_steam(): + w = watchers._Watcher(lambda: False) + sc = _FakeSC() + w.on_input(sc, _frame(SCButtons.QAM | SCButtons.X)) + assert w.triggered + + +def test_custom_open_chord_respected(): + w = watchers._Watcher(lambda: False, open_chord=SCButtons.B) + sc = _FakeSC() + w.on_input(sc, _frame(SCButtons.STEAM | SCButtons.X)) + assert not w.triggered # X is not this config's chord + w.on_input(sc, _frame(0)) + w.on_input(sc, _frame(SCButtons.STEAM | SCButtons.B)) + assert w.triggered + + +def test_chord_held_across_frames_fires_once(): + w = watchers._Watcher(lambda: False) + sc = _FakeSC() + w.on_input(sc, _frame(SCButtons.STEAM | SCButtons.X)) + w.on_input(sc, _frame(SCButtons.STEAM | SCButtons.X)) + assert sc.exits == 1 # rising edge only + + +def test_chord_state_release_alt(): + cs = watchers._ChordState() + cs.alt_held = True + cs.release_alt() + assert not cs.alt_held + cs.alt_held = True + cs.release_all_held() + assert not cs.alt_held From aabf60e2282b199e4fabb3fb984bf84f387fc2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 08:32:43 +0200 Subject: [PATCH 04/20] chore: restructure tests into per-module subdirs; pin sc settings in runner fixture --- ruff.toml | 3 +- windows/tests/{ => applog}/test_applog.py | 0 windows/tests/{ => applook}/test_applook.py | 0 .../{ => appsettings}/test_appsettings.py | 0 .../tests/{ => autostart}/test_autostart.py | 0 windows/tests/{ => color}/test_color_skins.py | 0 windows/tests/{ => config}/test_config.py | 0 windows/tests/conftest.py | 6 + .../{ => controller}/test_chords_media.py | 0 .../tests/{ => controller}/test_controller.py | 0 windows/tests/controller/test_pad_pointer.py | 88 ++++ .../{ => controller}/test_select_flow.py | 0 .../test_session_lifecycle.py | 0 .../test_triggers_actuation.py | 0 .../{ => cursor_ctrl}/test_cursor_ctrl.py | 0 .../{ => cursor_helper}/test_cursor_helper.py | 0 windows/tests/{ => fonts}/test_fonts.py | 0 windows/tests/{ => icon}/test_icon.py | 0 .../tests/{ => inputsrc}/test_hid_source.py | 0 .../{ => inputsrc}/test_inputsrc_merge.py | 0 .../tests/{ => key_sound}/test_key_sound.py | 0 .../{ => mousehook}/test_mousehook_proc.py | 0 windows/tests/sc_runner.py | 423 ++++++++++++++++++ windows/tests/{ => screen}/test_sdl_e2e.py | 0 .../test_single_instance.py | 0 windows/tests/{ => state}/test_state.py | 0 .../{ => steam_assets}/test_steam_assets.py | 0 .../test_steam_shortcut.py | 2 +- windows/tests/{ => tray}/test_tray_helpers.py | 0 .../{ => tray}/test_tray_menu_battery.py | 0 .../tests/{ => utils}/test_utils_geometry.py | 0 windows/tests/vkb/test_diacritic_hold.py | 142 ++++++ windows/tests/{ => vkb}/test_diacritics.py | 0 windows/tests/vkb/test_nav_cursor.py | 101 +++++ windows/tests/vkb/test_typing_flow.py | 159 +++++++ windows/tests/{ => vptr}/test_vptr.py | 0 windows/tests/{ => watchers}/test_watchers.py | 0 windows/tests/{ => win32}/test_win_focus.py | 0 .../tests/{ => windowpos}/test_windowpos.py | 0 39 files changed, 922 insertions(+), 2 deletions(-) mode change 100755 => 100644 ruff.toml rename windows/tests/{ => applog}/test_applog.py (100%) mode change 100755 => 100644 rename windows/tests/{ => applook}/test_applook.py (100%) mode change 100755 => 100644 rename windows/tests/{ => appsettings}/test_appsettings.py (100%) mode change 100755 => 100644 rename windows/tests/{ => autostart}/test_autostart.py (100%) mode change 100755 => 100644 rename windows/tests/{ => color}/test_color_skins.py (100%) rename windows/tests/{ => config}/test_config.py (100%) mode change 100755 => 100644 rename windows/tests/{ => controller}/test_chords_media.py (100%) rename windows/tests/{ => controller}/test_controller.py (100%) mode change 100755 => 100644 create mode 100644 windows/tests/controller/test_pad_pointer.py rename windows/tests/{ => controller}/test_select_flow.py (100%) rename windows/tests/{ => controller}/test_session_lifecycle.py (100%) rename windows/tests/{ => controller}/test_triggers_actuation.py (100%) rename windows/tests/{ => cursor_ctrl}/test_cursor_ctrl.py (100%) rename windows/tests/{ => cursor_helper}/test_cursor_helper.py (100%) rename windows/tests/{ => fonts}/test_fonts.py (100%) rename windows/tests/{ => icon}/test_icon.py (100%) mode change 100755 => 100644 rename windows/tests/{ => inputsrc}/test_hid_source.py (100%) rename windows/tests/{ => inputsrc}/test_inputsrc_merge.py (100%) rename windows/tests/{ => key_sound}/test_key_sound.py (100%) rename windows/tests/{ => mousehook}/test_mousehook_proc.py (100%) create mode 100644 windows/tests/sc_runner.py rename windows/tests/{ => screen}/test_sdl_e2e.py (100%) rename windows/tests/{ => single_instance}/test_single_instance.py (100%) mode change 100755 => 100644 rename windows/tests/{ => state}/test_state.py (100%) mode change 100755 => 100644 rename windows/tests/{ => steam_assets}/test_steam_assets.py (100%) mode change 100755 => 100644 rename windows/tests/{ => steam_shortcut}/test_steam_shortcut.py (100%) mode change 100755 => 100644 rename windows/tests/{ => tray}/test_tray_helpers.py (100%) rename windows/tests/{ => tray}/test_tray_menu_battery.py (100%) rename windows/tests/{ => utils}/test_utils_geometry.py (100%) create mode 100644 windows/tests/vkb/test_diacritic_hold.py rename windows/tests/{ => vkb}/test_diacritics.py (100%) mode change 100755 => 100644 create mode 100644 windows/tests/vkb/test_nav_cursor.py create mode 100644 windows/tests/vkb/test_typing_flow.py rename windows/tests/{ => vptr}/test_vptr.py (100%) rename windows/tests/{ => watchers}/test_watchers.py (100%) rename windows/tests/{ => win32}/test_win_focus.py (100%) mode change 100755 => 100644 rename windows/tests/{ => windowpos}/test_windowpos.py (100%) mode change 100755 => 100644 diff --git a/ruff.toml b/ruff.toml old mode 100755 new mode 100644 index fb41dc7..ae21244 --- a/ruff.toml +++ b/ruff.toml @@ -13,4 +13,5 @@ ignore = [ [lint.per-file-ignores] # Tests may use bare asserts and fixtures with unused params. -"tests/*.py" = ["B017"] +# (** crosses the per-module subdirs: tests//.py) +"tests/**/*.py" = ["B017"] diff --git a/windows/tests/test_applog.py b/windows/tests/applog/test_applog.py old mode 100755 new mode 100644 similarity index 100% rename from windows/tests/test_applog.py rename to windows/tests/applog/test_applog.py diff --git a/windows/tests/test_applook.py b/windows/tests/applook/test_applook.py old mode 100755 new mode 100644 similarity index 100% rename from windows/tests/test_applook.py rename to windows/tests/applook/test_applook.py diff --git a/windows/tests/test_appsettings.py b/windows/tests/appsettings/test_appsettings.py old mode 100755 new mode 100644 similarity index 100% rename from windows/tests/test_appsettings.py rename to windows/tests/appsettings/test_appsettings.py diff --git a/windows/tests/test_autostart.py b/windows/tests/autostart/test_autostart.py old mode 100755 new mode 100644 similarity index 100% rename from windows/tests/test_autostart.py rename to windows/tests/autostart/test_autostart.py diff --git a/windows/tests/test_color_skins.py b/windows/tests/color/test_color_skins.py similarity index 100% rename from windows/tests/test_color_skins.py rename to windows/tests/color/test_color_skins.py diff --git a/windows/tests/test_config.py b/windows/tests/config/test_config.py old mode 100755 new mode 100644 similarity index 100% rename from windows/tests/test_config.py rename to windows/tests/config/test_config.py diff --git a/windows/tests/conftest.py b/windows/tests/conftest.py index c3787ce..37d9749 100755 --- a/windows/tests/conftest.py +++ b/windows/tests/conftest.py @@ -16,6 +16,12 @@ os.environ.setdefault("TRITON_DATA", os.path.join(_ROOT, "data")) if _ROOT not in sys.path: sys.path.insert(0, _ROOT) +# Tests live in per-module subdirs (tests//...), so pytest's +# per-file sys.path insertion no longer exposes tests/ itself — add it +# explicitly for the shared harness lib (sc_runner). +_TESTS = os.path.dirname(os.path.abspath(__file__)) +if _TESTS not in sys.path: + sys.path.insert(0, _TESTS) # Headless tests must never write into the REAL %APPDATA%\DualTouch\dualtouch.log # (the diagnostic log the user reads after live tests). The injection/pad diff --git a/windows/tests/test_chords_media.py b/windows/tests/controller/test_chords_media.py similarity index 100% rename from windows/tests/test_chords_media.py rename to windows/tests/controller/test_chords_media.py diff --git a/windows/tests/test_controller.py b/windows/tests/controller/test_controller.py old mode 100755 new mode 100644 similarity index 100% rename from windows/tests/test_controller.py rename to windows/tests/controller/test_controller.py diff --git a/windows/tests/controller/test_pad_pointer.py b/windows/tests/controller/test_pad_pointer.py new file mode 100644 index 0000000..41e8aae --- /dev/null +++ b/windows/tests/controller/test_pad_pointer.py @@ -0,0 +1,88 @@ +"""Layer 2: pad pointer plumbing — raw->pixel mapping, click-button insert +under the pointer, settle-window debounce, force hysteresis, and the +pad-force click path (sc_pad_click_enter).""" + +from steamcontroller import SCButtons +from triton import diacritics, state +from triton.pad import _PadMixin + + +def test_published_pointer_lands_on_aimed_cell(runner): + runner.point("j") + px, py = runner.cell_center("j") + ptr = runner.cstate.get_pointers()[0] + x, y = ptr.coord_frac.to_absolute() + assert (round(x), round(y)) == (px, py) + + +def test_click_button_inserts_key_under_pointer(runner): + runner.click_cell("j") + assert runner.typed() == "j" + assert runner.pad_haptics >= 1 # click rumble on the press edge + + +def test_settle_window_swallows_force_wobble(runner): + """A re-engage inside PAD_CLICK_SETTLE (0.05 s) is a phantom second + insert and must be swallowed; a fast-but-human retype still fires.""" + letter = next( + lbl + for lbl in ("q", "w", "p", "y") + if diacritics.lookup_variants( + diacritics.DIACRITIC_VARIANTS, "en", lbl + ) + is None + ) + pos = runner.raw_at_cell(letter) + t = runner.clock.t + runner.push(buttons=SCButtons.LB, **pos) # press #1 -> types + runner.advance(0.01) + runner.push(buttons=0, **pos) # release + runner.clock.t = t + 0.02 # re-engage well inside the settle window + runner.push(buttons=SCButtons.LB, **pos) # wobble: swallowed + runner.push(buttons=0, **pos) + assert runner.typed() == letter + + +class _Hyst(_PadMixin): + def __init__(self): + self._pad_click_engage = 2500 + self._pad_click_release = 1000 + + press_click = _PadMixin._press_click + + +def test_press_click_hysteresis(): + h = _Hyst() + assert h._press_click(2600, False) # above engage: click + assert not h._press_click(500, True) # below release: unclick + assert not h._press_click(1500, False) # mid band holds previous... + assert h._press_click(1500, True) # ...in both directions + + +def test_pad_force_click_path_inserts(runner): + """sc_pad_click_enter ON: physical pad force crossing ENGAGE clicks.""" + state.set_sc_pad_click_enter(True) + letter = next( + lbl + for lbl in ("q", "w", "p", "y") + if diacritics.lookup_variants( + diacritics.DIACRITIC_VARIANTS, "en", lbl + ) + is None + ) + pos = runner.raw_at_cell(letter) + touch = SCButtons.LPADTOUCH + runner.push(buttons=touch, lpad_press=2600, **pos) + assert runner.typed() == letter + runner.push(buttons=touch, lpad_press=0, **pos) # release: rumble + assert runner.pad_haptics >= 2 # press + release ticks + + +def test_raw_mapping_roundtrips_through_adjust(runner): + from triton.controller import adjust_raw_x, adjust_raw_y + + for label in ("a", "j", "Backspace", "Esc", "Space"): + px, py = runner.cell_center(label) + rx, ry = runner.raw_at_cell(label)["lpad"] + assert adjust_raw_x(rx, 1 / 4) == px + assert adjust_raw_y(ry, 1 / 2) == py diff --git a/windows/tests/test_select_flow.py b/windows/tests/controller/test_select_flow.py similarity index 100% rename from windows/tests/test_select_flow.py rename to windows/tests/controller/test_select_flow.py diff --git a/windows/tests/test_session_lifecycle.py b/windows/tests/controller/test_session_lifecycle.py similarity index 100% rename from windows/tests/test_session_lifecycle.py rename to windows/tests/controller/test_session_lifecycle.py diff --git a/windows/tests/test_triggers_actuation.py b/windows/tests/controller/test_triggers_actuation.py similarity index 100% rename from windows/tests/test_triggers_actuation.py rename to windows/tests/controller/test_triggers_actuation.py diff --git a/windows/tests/test_cursor_ctrl.py b/windows/tests/cursor_ctrl/test_cursor_ctrl.py similarity index 100% rename from windows/tests/test_cursor_ctrl.py rename to windows/tests/cursor_ctrl/test_cursor_ctrl.py diff --git a/windows/tests/test_cursor_helper.py b/windows/tests/cursor_helper/test_cursor_helper.py similarity index 100% rename from windows/tests/test_cursor_helper.py rename to windows/tests/cursor_helper/test_cursor_helper.py diff --git a/windows/tests/test_fonts.py b/windows/tests/fonts/test_fonts.py similarity index 100% rename from windows/tests/test_fonts.py rename to windows/tests/fonts/test_fonts.py diff --git a/windows/tests/test_icon.py b/windows/tests/icon/test_icon.py old mode 100755 new mode 100644 similarity index 100% rename from windows/tests/test_icon.py rename to windows/tests/icon/test_icon.py diff --git a/windows/tests/test_hid_source.py b/windows/tests/inputsrc/test_hid_source.py similarity index 100% rename from windows/tests/test_hid_source.py rename to windows/tests/inputsrc/test_hid_source.py diff --git a/windows/tests/test_inputsrc_merge.py b/windows/tests/inputsrc/test_inputsrc_merge.py similarity index 100% rename from windows/tests/test_inputsrc_merge.py rename to windows/tests/inputsrc/test_inputsrc_merge.py diff --git a/windows/tests/test_key_sound.py b/windows/tests/key_sound/test_key_sound.py similarity index 100% rename from windows/tests/test_key_sound.py rename to windows/tests/key_sound/test_key_sound.py diff --git a/windows/tests/test_mousehook_proc.py b/windows/tests/mousehook/test_mousehook_proc.py similarity index 100% rename from windows/tests/test_mousehook_proc.py rename to windows/tests/mousehook/test_mousehook_proc.py diff --git a/windows/tests/sc_runner.py b/windows/tests/sc_runner.py new file mode 100644 index 0000000..a2107c6 --- /dev/null +++ b/windows/tests/sc_runner.py @@ -0,0 +1,423 @@ +"""SceneRunner — GdUnit4-style scene runner for the DualTouch OSK. + +Drives the REAL production pipeline headlessly: every pushed frame flows +through controller.update() -> ControllerManager.handle_input() and then +triton.drain_input_work() — exactly what triton.main()'s loop runs each +iteration, minus the SDL window/render. OS injection surfaces (pynput +keyboard/mouse) are replaced by recorders, so tests assert typed output at +the OS boundary without touching the real desktop. + +Layers built on this module: + • Layer 1 — pure state-machine tests (vkb.step_cursor etc.), no runner. + • Layer 2 — synthetic input injection through SceneRunner (this file). +""" + + +import steamcontroller.uinput as sui +from steamcontroller import ( + SCButtons, + SCStatus, + SteamControllerInput, +) +from triton import config, controller, diacritics, screen, state, vkb, vptr +from triton.screen import CoordFraction +from triton.triton import drain_input_work + +# Frame cadence of the fake input thread (the real merger polls ~250 Hz; +# 60 Hz keeps hold-to-repeat math readable in whole frames). +FRAME = 1.0 / 60 + +# Raw-pad inverse of controller.adjust_raw_x/y (scalar 6/5 baked in). +_X_SCALE = 0x20000 / (6 / 5) +_Y_SCALE = -0x10000 / (6 / 5) + + +def raw_for_px(px, py, center_fraction_x=1 / 4): + """Raw lpad_x/lpad_y values whose pointer lands on pixel (px, py).""" + return ( + int(round(((px / screen.width) - center_fraction_x) * _X_SCALE)), + int(round(((py / screen.height) - 1 / 2) * _Y_SCALE)), + ) + + +# --- OS-boundary recorders ------------------------------------------- + + +class RecordingKeyboard: + """pynput.Keyboard stand-in shared by every injection surface. + + Events land on ONE class-level stream so output typed through vkb.kb, + ControllerManager._kb, and EventMapper's keyboard is interleaved in + true order.""" + + EVENTS: list = [] + + def __init__(self, *args, **kwargs): + self.down_keys: set = set() + + def _rec(self, kind, *detail): + RecordingKeyboard.EVENTS.append((kind, *detail)) + + def pressEvent(self, keys): + keys = tuple(keys) + self._rec("down", *keys) + self.down_keys.update(keys) + for k in keys: + if k not in ("KEY_LEFTSHIFT", "KEY_RIGHTSHIFT"): + self._type_char(k) + + def releaseEvent(self, keys): + keys = tuple(keys) + self._rec("up", *keys) + self.down_keys.difference_update(keys) + + def reset_shift_state(self): + self._rec("reset-shift") + + def tap_with_modifier(self, keycode, modifiers=()): + self._rec("tap-mod", keycode, tuple(modifiers)) + + def tap_char(self, char): + self._rec("char", char) + return True + + def force_caps_off(self): + self._rec("caps-off") + + def _release_stuck_ctrl(self): + self._rec("release-ctrl") + + def reset_modifier_state(self): + self._rec("reset-modifiers") + + def _type_char(self, keycode): + ch = _KEY_CHARS.get(keycode) + if ch is None: + return + if self.down_keys & {"KEY_LEFTSHIFT", "KEY_RIGHTSHIFT"}: + ch = ch.upper() + self._rec("char", ch) + + +class RecordingMouse: + """pynput.Mouse stand-in (right-stick cursor + trigger mouse holds).""" + + EVENTS: list = [] + + def move(self, dx, dy): + RecordingMouse.EVENTS.append(("move", dx, dy)) + + def press(self, button): + RecordingMouse.EVENTS.append(("press", button)) + + def release(self, button): + RecordingMouse.EVENTS.append(("release", button)) + + +_KEY_CHARS = { + getattr(sui.Keys, f"KEY_{c}"): c.lower() + for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +} +_KEY_CHARS[sui.Keys.KEY_BACKSPACE] = "\b" +_KEY_CHARS.update( + { + getattr(sui.Keys, name): ch + for name, ch in [ + ("KEY_SPACE", " "), + ("KEY_ENTER", "\n"), + ("KEY_TAB", "\t"), + ("KEY_GRAVE", "`"), + ("KEY_MINUS", "-"), + ("KEY_EQUAL", "="), + ("KEY_LEFTBRACE", "["), + ("KEY_RIGHTBRACE", "]"), + ("KEY_BACKSLASH", "\\"), + ("KEY_SEMICOLON", ";"), + ("KEY_APOSTROPHE", "'"), + ("KEY_COMMA", ","), + ("KEY_DOT", "."), + ("KEY_SLASH", "/"), + ("KEY_1", "1"), + ("KEY_2", "2"), + ("KEY_3", "3"), + ("KEY_4", "4"), + ("KEY_5", "5"), + ("KEY_6", "6"), + ("KEY_7", "7"), + ("KEY_8", "8"), + ("KEY_9", "9"), + ("KEY_0", "0"), + ] + } +) + + +class VirtualClock: + """Deterministic time.monotonic replacement (patched by the fixture). + Single-threaded harness: every module reads the same virtual now.""" + + def __init__(self): + self.t = 1000.0 + + def __call__(self): + return self.t + + def advance(self, dt=FRAME): + self.t += dt + + +class FakeSource: + """Minimal `sc` facade (the InputMerger contract): poll() returns None + so nothing else feeds frames; haptics/lizard/exit are recorded.""" + + def __init__(self): + self.exits = 0 + self.lizard_calls: list = [] + + def poll(self): + return None + + def set_lizard(self, on): + self.lizard_calls.append(bool(on)) + + def haptic_click(self): + pass + + def haptic_pad_click(self): + pass + + def addExit(self): + self.exits += 1 + + def close(self): + pass + + +def make_frame( + buttons=0, + ltrig=0.0, + rtrig=0.0, + lpad=(0, 0), + rpad=(0, 0), + lstick=(0, 0), + rstick=(0, 0), + lpad_press=0.0, + rpad_press=0.0, +): + """Build a real SteamControllerInput INPUT frame.""" + return SteamControllerInput( + status=SCStatus.INPUT, + seq=1, + buttons=int(buttons), + ltrig=ltrig, + rtrig=rtrig, + lpad_x=lpad[0], + lpad_y=lpad[1], + rpad_x=rpad[0], + rpad_y=rpad[1], + lstick_x=lstick[0], + lstick_y=lstick[1], + rstick_x=rstick[0], + rstick_y=rstick[1], + lpad_press=lpad_press, + rpad_press=rpad_press, + ) + + +class SceneRunner: + """One headless OSK session driven frame-by-frame.""" + + def __init__(self, clock=None): + RecordingKeyboard.EVENTS = [] + RecordingMouse.EVENTS = [] + state.reset_session() + state.set_steam_running(False) + state.set_rumble_enabled(True) + state.set_key_sound_enabled(True) + # The click queue is a CLASS attribute (shared across instances). + controller.ControllerState.click_queue.clear() + # Deterministic geometry + locale. + screen.width, screen.height = 1286, 369 + self.virtual_kb = self._build_kb() + self.virtual_kb.update_dimensions() + state.set_virtual_kb(self.virtual_kb) + state.set_diacritic_variants(diacritics.DIACRITIC_VARIANTS) + state.set_active_locale("en") + state.set_diacritics_enabled(True) + state.set_sc_click_button(None) # default LB/RB bumpers + state.set_sc_pad_click_enter(False) + # Pin every settings-backed knob the manager reads at __init__: + # other modules' roundtrip tests may have flipped these globals. + state.set_sc_kbd_stick_nav(True) + state.set_sc_osk_trigger_threshold(None) + # Feedback counters wired into the same hooks production uses. + self.sounds = 0 + self.haptics = 0 + self.pad_haptics = 0 + state.set_key_sound(lambda: setattr(self, "sounds", self.sounds + 1)) + state.set_haptic_tick( + lambda: setattr(self, "haptics", self.haptics + 1) + ) + state.set_pad_click_haptic( + lambda: setattr(self, "pad_haptics", self.pad_haptics + 1) + ) + self.clock = clock if clock is not None else VirtualClock() + # push() advances one FRAME by default; cadence tests flip this + # off to drive the clock in explicit steps. + self.auto_advance = True + self.src = FakeSource() + self.cstate = controller.ControllerState() + # Production seeds the pointer pair before the manager is built + # (get_pointers() must never be None on the first frame). + center = CoordFraction.from_absolute( + screen.width // 2, screen.height // 2 + ) + idle = vptr.VirtualPointer(state.InputState.INACTIVE, center) + self.cstate.set_pointers( + idle, + vptr.VirtualPointer(state.InputState.INACTIVE, center), + ) + self.manager = controller.ControllerManager(self.cstate) + # First real frame is swallowed as the edge-detection baseline. + self.push() + + @staticmethod + def _build_kb(): + cfg = config.YamlFile("keyboard-layout.yaml") + cfg.read() + cfg.add_to_config("keys", vkb.VirtualKeyboardConfig()) + kb = vkb.VirtualKeyboardConfig().construct() + kb.update_dimensions() + return kb + + # -- clock ----------------------------------------------------- + + def advance(self, dt=FRAME): + self.clock.advance(dt) + + # -- input ----------------------------------------------------- + + def push(self, **kw): + """Advance one frame, feed it through update(), drain the queues.""" + if self.auto_advance: + self.advance() + controller.update(self.src, make_frame(**kw), self.manager) + drain_input_work(self.cstate, self.virtual_kb) + + def push_idle(self, n=1): + for _ in range(n): + self.push() + + def tap_button(self, btn, **kw): + """Press+release a button around one idle frame.""" + kw["buttons"] = kw.get("buttons", 0) | btn + self.push(**kw) + self.push( + buttons=kw["buttons"] + & ~(btn | _dpad_bit(btn)), # drop dpad bits on release + ) + + def hold_button(self, btn, seconds, **kw): + """Hold a button for ~seconds across whole frames, then release.""" + kw["buttons"] = kw.get("buttons", 0) | btn + while seconds > 0: + self.push(**kw) + seconds -= FRAME + self.push( + buttons=kw["buttons"] & ~(btn | _dpad_bit(btn)), + ) + + def navigate(self, *directions): + """DPAD taps (edge per direction, released before the next).""" + bits = { + "UP": SCButtons.DPAD_UP, + "DOWN": SCButtons.DPAD_DOWN, + "LEFT": SCButtons.DPAD_LEFT, + "RIGHT": SCButtons.DPAD_RIGHT, + } + for d in directions: + self.tap_button(bits[d]) + + def stick(self, dx, dy, steam=False): + return dict( + lstick=(dx, dy), + buttons=SCButtons.STEAM if steam else 0, + ) + + # -- pointer --------------------------------------------------- + + def cell_rc(self, label): + """(row, col) of the key whose label matches, or None.""" + for r, row in enumerate(self.virtual_kb.keys): + for c, key in enumerate(row): + if key.str == label: + return (r, c) + return None + + def cell_center(self, label=None, *, rc=None): + r, c = rc if rc is not None else self.cell_rc(label) + layout = self.virtual_kb.get_key_layout(r, c) + assert layout is not None + return ( + layout.x + layout.w // 2, + layout.y + layout.h // 2, + ) + + def raw_at_cell(self, label=None, *, rc=None, side="left"): + px, py = self.cell_center(label, rc=rc) + cfx = 1 / 4 if side == "left" else 3 / 4 + rx, ry = raw_for_px(px, py, cfx) + return {"lpad": (rx, ry)} if side == "left" else {"rpad": (rx, ry)} + + def _raw_at_px(self, px, py): + """Left-pad raw values landing the pointer on an absolute pixel.""" + return {"lpad": raw_for_px(px, py)} + + def point(self, label=None, *, rc=None): + """Park the left-pad pointer on a cell (touch, no click).""" + kw: dict = self.raw_at_cell(label, rc=rc) + kw["buttons"] = SCButtons.LPADTOUCH + self.push(**kw) + + def click_cell(self, label=None, *, rc=None, side="left"): + """Click-button insert path: LB/RB held with the pointer parked.""" + btn = SCButtons.LB if side == "left" else SCButtons.RB + pos = self.raw_at_cell(label, rc=rc, side=side) + self.push(buttons=btn | SCButtons.LPADTOUCH, **pos) + self.push(buttons=0, **pos) + + # -- queries --------------------------------------------------- + + @staticmethod + def events(): + return list(RecordingKeyboard.EVENTS) + + @staticmethod + def typed(): + """Net typed text from the recorder stream (backspaces applied).""" + out = [] + for ev in RecordingKeyboard.EVENTS: + if ev[0] != "char": + continue + ch = ev[1] + if ch == "KEY_BACKSPACE" or ch == "\b": + if out: + out.pop() + elif isinstance(ch, str) and len(ch) == 1: + out.append(ch) + return "".join(out) + + def cursor(self): + return state.get_cursor() + + +_DPAD = { + SCButtons.DPAD_UP, + SCButtons.DPAD_DOWN, + SCButtons.DPAD_LEFT, + SCButtons.DPAD_RIGHT, +} + + +def _dpad_bit(btn): + return btn if btn in _DPAD else 0 diff --git a/windows/tests/test_sdl_e2e.py b/windows/tests/screen/test_sdl_e2e.py similarity index 100% rename from windows/tests/test_sdl_e2e.py rename to windows/tests/screen/test_sdl_e2e.py diff --git a/windows/tests/test_single_instance.py b/windows/tests/single_instance/test_single_instance.py old mode 100755 new mode 100644 similarity index 100% rename from windows/tests/test_single_instance.py rename to windows/tests/single_instance/test_single_instance.py diff --git a/windows/tests/test_state.py b/windows/tests/state/test_state.py old mode 100755 new mode 100644 similarity index 100% rename from windows/tests/test_state.py rename to windows/tests/state/test_state.py diff --git a/windows/tests/test_steam_assets.py b/windows/tests/steam_assets/test_steam_assets.py old mode 100755 new mode 100644 similarity index 100% rename from windows/tests/test_steam_assets.py rename to windows/tests/steam_assets/test_steam_assets.py diff --git a/windows/tests/test_steam_shortcut.py b/windows/tests/steam_shortcut/test_steam_shortcut.py old mode 100755 new mode 100644 similarity index 100% rename from windows/tests/test_steam_shortcut.py rename to windows/tests/steam_shortcut/test_steam_shortcut.py index 2ea8cbd..62bce89 --- a/windows/tests/test_steam_shortcut.py +++ b/windows/tests/steam_shortcut/test_steam_shortcut.py @@ -304,9 +304,9 @@ def fake_chord(tmpdir, monkeypatch): case: only the shipped base template exists (in controller_base, named chord_triton.vdf), and the base template has no button_a slot.""" from steam_shortcut import ( + _CHORD_APPID_DIR, _CHORD_BASE_TEMPLATE, _CHORD_CONFIG_SUBDIR, - _CHORD_APPID_DIR, _CHORD_FILENAME, ) diff --git a/windows/tests/test_tray_helpers.py b/windows/tests/tray/test_tray_helpers.py similarity index 100% rename from windows/tests/test_tray_helpers.py rename to windows/tests/tray/test_tray_helpers.py diff --git a/windows/tests/test_tray_menu_battery.py b/windows/tests/tray/test_tray_menu_battery.py similarity index 100% rename from windows/tests/test_tray_menu_battery.py rename to windows/tests/tray/test_tray_menu_battery.py diff --git a/windows/tests/test_utils_geometry.py b/windows/tests/utils/test_utils_geometry.py similarity index 100% rename from windows/tests/test_utils_geometry.py rename to windows/tests/utils/test_utils_geometry.py diff --git a/windows/tests/vkb/test_diacritic_hold.py b/windows/tests/vkb/test_diacritic_hold.py new file mode 100644 index 0000000..7c50844 --- /dev/null +++ b/windows/tests/vkb/test_diacritic_hold.py @@ -0,0 +1,142 @@ +"""Layer 2: the diacritic defer model (Feature B) through pad click and +A-button paths — quick tap types the base silently, a hold opens the +variant row, release commits base vs variant.""" + +import pytest +from steamcontroller import SCButtons +from triton import diacritics, state + +_EN_A = diacritics.lookup_variants( + diacritics.DIACRITIC_VARIANTS, "en", "a" +) + + +@pytest.fixture +def en_a(): + assert _EN_A and len(_EN_A) >= 3 + return _EN_A + + +def _hold_open_row(runner, label="a"): + """Press LB on a cell and hold past KEY_REPEAT_DELAY so the variant row + opens (source 'pad'). Explicit clock steps; auto-advance is restored + before returning so later pushes flow normally.""" + runner.auto_advance = False + pos = runner.raw_at_cell(label) + runner.advance(0.02) + runner.push(buttons=SCButtons.LB, **pos) # press edge: defer + sound + for _ in range(8): # 8 * 0.06 s clears the 0.4 s delay mid-band + runner.advance(0.06) + runner.push(buttons=SCButtons.LB, **pos) + assert state.is_diacritic_open() + assert state.get_diacritic_source() == "pad" + runner.auto_advance = True + return pos + + +def test_quick_tap_types_base_silently(runner, en_a): + rc = runner.cell_rc("a") + state.set_cursor(*rc) # irrelevant; the PAD path types here + runner.click_cell("a") + assert runner.typed() == "a" + assert not state.is_diacritic_open() + # One click at the press edge; the release commit is silent. + assert runner.sounds == 1 + + +def test_hold_commits_variant_under_finger_x(runner, en_a): + pos = _hold_open_row(runner) + assert list(state.get_diacritic_variants_list()) == list(en_a) + # The resting finger already highlights whatever slot its x falls in. + rect = state.get_diacritic_rect() + n = state.get_diacritic_variant_count() + px, py = runner.cell_center("a") + picked = diacritics.variant_index_at_point(rect, px, py, n) + runner.push(buttons=0, **pos) # release commits the highlighted one + assert runner.typed() == en_a[picked] + assert not state.is_diacritic_open() + assert runner.sounds == 1 # press edge only + + +def test_hold_with_finger_off_strip_defaults_to_first(runner, en_a): + """A release whose finger never sat in the strip commits the FIRST + variant, never the base letter.""" + _hold_open_row(runner) + state.set_diacritic_index(-1) # simulate no explicit pick + off = runner._raw_at_px(2, 360) # bottom-left corner: outside slots + runner.push(buttons=SCButtons.LB, **off) + runner.push(buttons=0, **off) + assert runner.typed() == en_a[0] + + +def test_finger_x_picks_variant_on_release(runner, en_a): + _hold_open_row(runner) + rect = state.get_diacritic_rect() + n = state.get_diacritic_variant_count() + slot = 2 + px = rect[0] + rect[2] * (slot + 0.5) / n + py = rect[1] + rect[3] / 2 + over = runner.raw_at_cell(rc=runner.cell_rc("a")) + # Slide the finger into the variant strip while still holding LB. + runner.push(buttons=SCButtons.LB, lpad=over["lpad"]) + runner.push(buttons=SCButtons.LB, **runner._raw_at_px(px, py)) + assert state.get_diacritic_selected_char() == en_a[slot] + runner.push(buttons=0, **runner._raw_at_px(px, py)) + assert runner.typed() == en_a[slot] + + +def test_dpad_moves_selection_while_row_open(runner, en_a): + """A-button variant: while the row is open via A, DPAD left/right steps + the selection and A-release commits it (no finger to fight).""" + runner.auto_advance = False + state.set_cursor(*runner.cell_rc("a")) + runner.advance(0.02) + runner.push(buttons=SCButtons.A) + for _ in range(8): + runner.advance(0.06) + runner.push(buttons=SCButtons.A) + assert state.get_diacritic_source() == "a" + # From "no pick" (-1), the first RIGHT selects variant 0. Each further + # step needs a fresh DPAD edge, so drop the bit between taps. + runner.push(buttons=SCButtons.A | SCButtons.DPAD_RIGHT) + assert state.get_diacritic_index() == 0 + runner.push(buttons=SCButtons.A) + runner.push(buttons=SCButtons.A | SCButtons.DPAD_RIGHT) + assert state.get_diacritic_index() == 1 + runner.push(buttons=SCButtons.A) # keep holding; selection stays + assert state.get_diacritic_index() == 1 + runner.auto_advance = True + runner.push() # release commits the picked variant + assert runner.typed() == en_a[1] + + +def test_a_button_hold_opens_row_and_commits_first(runner, en_a): + runner.auto_advance = False + state.set_cursor(*runner.cell_rc("a")) + runner.advance(0.02) + runner.push(buttons=SCButtons.A) # press edge: defer + click sound + for _ in range(8): + runner.advance(0.06) + runner.push(buttons=SCButtons.A) + assert state.is_diacritic_open() + assert state.get_diacritic_source() == "a" + runner.auto_advance = True + runner.push() + assert runner.typed() == en_a[0] + assert runner.sounds == 1 + + +def test_a_button_quick_tap_types_base_silently(runner): + state.set_cursor(*runner.cell_rc("a")) + runner.tap_button(SCButtons.A) + assert runner.typed() == "a" + assert runner.sounds == 1 + + +def test_disabled_diacritics_type_immediately_at_press_edge(runner): + state.set_diacritics_enabled(False) + pos = runner.raw_at_cell("a") + runner.push(buttons=SCButtons.LB, **pos) + assert runner.typed() == "a" # already typed BEFORE release + runner.push(buttons=0, **pos) + assert runner.typed() == "a" diff --git a/windows/tests/test_diacritics.py b/windows/tests/vkb/test_diacritics.py old mode 100755 new mode 100644 similarity index 100% rename from windows/tests/test_diacritics.py rename to windows/tests/vkb/test_diacritics.py diff --git a/windows/tests/vkb/test_nav_cursor.py b/windows/tests/vkb/test_nav_cursor.py new file mode 100644 index 0000000..786c09d --- /dev/null +++ b/windows/tests/vkb/test_nav_cursor.py @@ -0,0 +1,101 @@ +"""Layer 1: cursor state machine (vkb.step_cursor) — no controller needed. + +Plus Layer 2: the left-stick navigation zones driven through the REAL +ControllerManager (edge -> hold-delay -> repeat cadence, virtual clock). +""" + +import pytest +from steamcontroller import SCButtons +from triton import state, vkb + + +@pytest.fixture +def kb(runner): + return runner.virtual_kb + + +def test_left_wraps_to_row_end(runner, kb): + state.set_cursor(2, 0) + vkb.step_cursor(kb, "LEFT") + assert state.get_cursor() == (2, len(kb.keys[2]) - 1) + + +def test_right_wraps_to_row_start(runner, kb): + state.set_cursor(2, len(kb.keys[2]) - 1) + vkb.step_cursor(kb, "RIGHT") + assert state.get_cursor() == (2, 0) + + +def test_up_maps_by_pixel_column(runner, kb): + r, c = 2, 5 # 't' + layout = kb.get_key_layout(r, c) + x_center = layout.x + layout.w // 2 + expected = kb.find_col_at_x(r - 1, x_center) + state.set_cursor(r, c) + vkb.step_cursor(kb, "UP") + assert state.get_cursor() == (r - 1, expected) + + +def test_down_honors_key_override(runner, kb): + # The ',' key pins dpad_down: col 3 (see keyboard-layout.yaml). + comma = runner.cell_rc(",") + state.set_cursor(*comma) + vkb.step_cursor(kb, "DOWN") + assert state.get_cursor() == (comma[0] + 1, 3) + assert state.is_cursor_used() + + +def test_up_clamps_at_top_row(runner, kb): + state.set_cursor(0, 4) + vkb.step_cursor(kb, "UP") + assert state.get_cursor() == (0, 4) + + +def test_stick_edge_then_hold_repeat_cadence(runner): + """One step on crossing the deadzone, then hold-delay 0.35 s, then a + step every 0.15 s — explicit clock steps so no frame lands on a + threshold.""" + runner.auto_advance = False + start = state.get_cursor() + # Rising edge: exactly one step. + runner.advance(0.03) + runner.push(lstick=(20000, 0)) + assert state.get_cursor() == (start[0], start[1] + 1) + # Hold 0.9 s in 0.06 s frames: repeats at 0.39/0.57/0.75/0.93 -> +4. + for _ in range(15): + runner.advance(0.06) + runner.push(lstick=(20000, 0)) + assert state.get_cursor() == (start[0], start[1] + 5) + assert runner.haptics == 1 + 5 # open tick + one per stick step + # Release: zone reset, no phantom extra steps. + runner.advance(0.05) + runner.push() + runner.advance(0.05) + runner.push() + assert state.get_cursor() == (start[0], start[1] + 5) + + +def test_stick_below_deadzone_does_not_navigate(runner): + start = state.get_cursor() + for _ in range(10): + runner.push(lstick=(10000, 0)) # < KBD_STICK_DEADZONE (18480) + assert state.get_cursor() == start + + +def test_stick_steps_carry_haptic_tick(runner): + # Baseline frame already spent the open tick; each stick step buzzes. + base = runner.haptics + runner.push(lstick=(20000, 0)) + assert runner.haptics == base + 1 + + +def test_steam_held_routes_stick_to_media(runner): + """Steam + stick = media transport, NOT cursor navigation.""" + start = state.get_cursor() + runner.push(lstick=(0, -20000), buttons=SCButtons.STEAM) + for _ in range(10): + runner.advance(0.03) + runner.push(lstick=(0, -20000), buttons=SCButtons.STEAM) + assert state.get_cursor() == start + names = [ev[1] for ev in runner.events() if ev[0] == "down"] + assert any(n.startswith("KEY_VOLUME") for n in names) diff --git a/windows/tests/vkb/test_typing_flow.py b/windows/tests/vkb/test_typing_flow.py new file mode 100644 index 0000000..61f71c5 --- /dev/null +++ b/windows/tests/vkb/test_typing_flow.py @@ -0,0 +1,159 @@ +"""Layer 2: typing flows through the real pipeline — chords, button roles, +modifier latches, hold-to-repeat cadence and feedback hooks.""" + +from steamcontroller import SCButtons +from triton import state, vkb + + +def _type_at(runner, label): + rc = runner.cell_rc(label) + state.set_cursor(*rc) + runner.push(buttons=SCButtons.A) + runner.push() + + +def test_a_inserts_key_under_cursor(runner): + _type_at(runner, "h") + _type_at(runner, "i") + assert runner.typed() == "hi" + + +def test_y_and_grips_are_space(runner): + runner.tap_button(SCButtons.Y) + runner.tap_button(SCButtons.RGRIP1) + runner.tap_button(SCButtons.RGRIP2) + assert runner.typed() == " " + + +def test_x_taps_backspace(runner): + _type_at(runner, "a") + _type_at(runner, "b") + runner.tap_button(SCButtons.X) + assert runner.typed() == "a" + + +def test_x_hold_repeats_on_backspace_cadence(runner): + """Delete on press, then every KEY_REPEAT_INTERVAL after KEY_REPEAT_DELAY + — exact counts under the virtual clock (explicit 49 ms steps never land + on a threshold).""" + runner.auto_advance = False + for label in ("a", "s", "d", "f", "g", "h", "j", "k", "l"): + _type_at(runner, label) + runner.advance(0.02) + runner.push(buttons=SCButtons.X) # press: delete #1 + for _ in range(22): # hold ~1.08 s more + runner.advance(0.049) + runner.push(buttons=SCButtons.X) + runner.advance(0.02) + runner.push() + # Repeats at ~0.46/0.61/0.76/0.90/1.05 -> 5 repeats + press delete. + assert runner.typed() == "asd" + # Repeats must not machine-gun the click sound. + assert runner.sounds >= 1 + + +def test_b_closes_keyboard_and_update_signals_exit(runner): + runner.tap_button(SCButtons.B) + assert state.should_close() + runner.src.exits = 0 + runner.push() + assert runner.src.exits == 1 + + +def test_lgrip_closes_keyboard(runner): + runner.push(buttons=SCButtons.LGRIP) + assert state.should_close() + + +def test_steam_alone_release_closes_after_grace(runner): + """The opening chord's Steam is seeded consumed; a fresh Steam + press->release past the 1.0 s grace closes the keyboard.""" + runner.auto_advance = False + runner.advance(0.05) + runner.push(buttons=SCButtons.STEAM) # rising edge: marks unused + for _ in range(24): + runner.advance(0.05) + runner.push(buttons=SCButtons.STEAM) + runner.advance(0.05) + runner.push() # release, well past the grace window + assert state.should_close() + + +def test_steam_within_grace_release_does_not_close(runner): + runner.auto_advance = False + runner.advance(0.05) + runner.push(buttons=SCButtons.STEAM) + runner.advance(0.2) + runner.push(buttons=SCButtons.STEAM) + runner.advance(0.05) + runner.push() + assert not state.should_close() + + +def test_steam_plus_x_chord_keeps_open_after_release(runner): + """Steam+X marks the Steam press 'used', so its release never closes.""" + runner.auto_advance = False + runner.advance(0.05) + runner.push(buttons=SCButtons.STEAM | SCButtons.X) + for _ in range(24): + runner.advance(0.05) + runner.push(buttons=SCButtons.STEAM | SCButtons.X) + runner.advance(0.05) + runner.push() + assert not state.should_close() + + +def test_l3_caps_lock_event_and_highlight(runner): + runner.push(buttons=SCButtons.L3) + names = [ev[1] for ev in runner.events()] + assert "KEY_CAPSLOCK" in names + assert vkb.sui.Keys.KEY_CAPSLOCK in state.get_highlighted() + + +def test_shift_latch_via_click_button(runner): + runner.click_cell("Shift") + assert state.is_shift_latched() + assert state.is_shift_held() + runner.click_cell("a") + assert runner.typed() == "A" + runner.click_cell("Shift") + assert not state.is_shift_latched() + runner.click_cell("a") + assert runner.typed() == "Aa" + + +def test_ctrl_alt_latches_via_click_button(runner): + runner.click_cell("Ctrl") + assert state.is_ctrl_latched() + runner.push_idle(4) # clear the pad-click settle window + runner.click_cell("Alt") + assert state.is_alt_latched() + marked = set(state.get_highlighted()) + assert { + vkb.sui.Keys.KEY_LEFTCTRL, + vkb.sui.Keys.KEY_LEFTALT, + } <= marked + + +def test_key_sound_ticks_once_per_press_not_repeat(runner): + state.set_cursor(0, 13) # Backspace under the cursor + runner.push(buttons=SCButtons.A) # press edge: one click sound + for _ in range(12): # held 0.6 s -> several silent auto-repeats + runner.advance(0.05) + runner.push(buttons=SCButtons.A) + runner.push() + assert runner.sounds == 1 + + +def test_a_paints_cursor_cell_while_held(runner): + rc = (3, 1) + state.set_cursor(*rc) + runner.push(buttons=SCButtons.A) + assert state.get_mouse_press_cell() == rc + runner.push() + assert state.get_mouse_press_cell() is None + + +def test_x_highlights_backspace_while_held(runner): + runner.push(buttons=SCButtons.X) + assert vkb.sui.Keys.KEY_BACKSPACE in state.get_highlighted() diff --git a/windows/tests/test_vptr.py b/windows/tests/vptr/test_vptr.py similarity index 100% rename from windows/tests/test_vptr.py rename to windows/tests/vptr/test_vptr.py diff --git a/windows/tests/test_watchers.py b/windows/tests/watchers/test_watchers.py similarity index 100% rename from windows/tests/test_watchers.py rename to windows/tests/watchers/test_watchers.py diff --git a/windows/tests/test_win_focus.py b/windows/tests/win32/test_win_focus.py old mode 100755 new mode 100644 similarity index 100% rename from windows/tests/test_win_focus.py rename to windows/tests/win32/test_win_focus.py diff --git a/windows/tests/test_windowpos.py b/windows/tests/windowpos/test_windowpos.py old mode 100755 new mode 100644 similarity index 100% rename from windows/tests/test_windowpos.py rename to windows/tests/windowpos/test_windowpos.py From e2ebc508f8127a6184fa3a35658e15d6e5396887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 08:42:15 +0200 Subject: [PATCH 05/20] test: drop duplicate smoke/L3-caps cases and dead patch line --- windows/tests/controller/test_chords_media.py | 6 ------ windows/tests/tray/test_tray_helpers.py | 1 - 2 files changed, 7 deletions(-) diff --git a/windows/tests/controller/test_chords_media.py b/windows/tests/controller/test_chords_media.py index c6f01b6..352d180 100644 --- a/windows/tests/controller/test_chords_media.py +++ b/windows/tests/controller/test_chords_media.py @@ -29,12 +29,6 @@ def test_steam_l3_is_playpause_and_marks_chord_used(runner): assert not state.should_close() # the chord marked Steam "used" -def test_plain_l3_is_caps_lock(runner): - runner.tap_button(SCButtons.L3) - downs = [ev[1] for ev in runner.events() if ev[0] == "down"] - assert "KEY_CAPSLOCK" in downs - - def test_steam_view_is_alt_tab_chord(runner): runner.push(buttons=SCButtons.STEAM | SCButtons.VIEW) events = [(ev[0], ev[1]) for ev in runner.events()] diff --git a/windows/tests/tray/test_tray_helpers.py b/windows/tests/tray/test_tray_helpers.py index 9184d0f..5bd0248 100644 --- a/windows/tests/tray/test_tray_helpers.py +++ b/windows/tests/tray/test_tray_helpers.py @@ -87,7 +87,6 @@ def test_relaunch_elevated_reports_failure(monkeypatch): def fail(*a, **kw): raise OSError("cancelled") - monkeypatch.setattr(helpers.ctypes, "windll", fail.__self__ if False else helpers.ctypes.windll) # ShellExecuteW raising (user cancels UAC) -> False, no crash. monkeypatch.setattr( helpers.ctypes.windll.shell32, From af170fc05f8628974ac59c4e3ddce27acf2cd983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 09:09:10 +0200 Subject: [PATCH 06/20] =?UTF-8?q?chore:=20pre-commit=20clean=20=E2=80=94?= =?UTF-8?q?=20ruff-format=20all=20ref-diff=20files,=20pyright-tight=20test?= =?UTF-8?q?=20annotations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- windows/build.py | 46 ++++++++++++++----- windows/cursor_ctrl.py | 12 +++-- windows/steam_shortcut.py | 30 ++++++------ windows/tests/applog/test_applog.py | 1 + windows/tests/conftest.py | 43 +++++++++++++++++ windows/tests/controller/test_chords_media.py | 13 +++--- windows/tests/controller/test_pad_pointer.py | 8 +--- windows/tests/controller/test_select_flow.py | 14 ++---- .../controller/test_session_lifecycle.py | 25 +++------- windows/tests/cursor_ctrl/test_cursor_ctrl.py | 4 +- .../tests/cursor_helper/test_cursor_helper.py | 4 +- windows/tests/inputsrc/test_hid_source.py | 3 +- windows/tests/inputsrc/test_inputsrc_merge.py | 16 ++----- windows/tests/sc_runner.py | 7 ++- .../steam_shortcut/test_steam_shortcut.py | 10 ++-- windows/tests/tray/test_tray_menu_battery.py | 8 ++-- windows/tests/vkb/test_diacritic_hold.py | 5 +- windows/tests/vptr/test_vptr.py | 1 - 18 files changed, 146 insertions(+), 104 deletions(-) mode change 100755 => 100644 windows/build.py mode change 100755 => 100644 windows/cursor_ctrl.py mode change 100755 => 100644 windows/steam_shortcut.py diff --git a/windows/build.py b/windows/build.py old mode 100755 new mode 100644 index 4a364c4..cace38b --- a/windows/build.py +++ b/windows/build.py @@ -78,8 +78,10 @@ def _preflight(): def _clean_previous(): - for stale in [OUT_DIR] + glob.glob(os.path.join(DIST_DIR, "*.dist")) + glob.glob( - os.path.join(DIST_DIR, "*.build") + for stale in ( + [OUT_DIR] + + glob.glob(os.path.join(DIST_DIR, "*.dist")) + + glob.glob(os.path.join(DIST_DIR, "*.build")) ): if os.path.isdir(stale): shutil.rmtree(stale, ignore_errors=True) @@ -110,7 +112,10 @@ def _ver_block(key, value=b"", vtype=0, children=b""): def _fixed_file_info(version): - parts = [int(p) if p.isdigit() else 0 for p in (version.split(".") + ["0"] * 4)[:4]] + parts = [ + int(p) if p.isdigit() else 0 + for p in (version.split(".") + ["0"] * 4)[:4] + ] ms_hi, ms_lo, ls_hi, ls_lo = parts return struct.pack( " Steam input key in the four_buttons group. _CHORD_SLOTS = { @@ -1164,19 +1162,19 @@ def _insert_chord_slot(text, slot): return None block = ( "\n" - '\t\t\t"{slot}"\n' - "\t\t\t{{\n" + f'\t\t\t"{slot}"\n' + "\t\t\t{\n" '\t\t\t\t"activators"\n' - "\t\t\t\t{{\n" + "\t\t\t\t{\n" '\t\t\t\t\t"Full_Press"\n' - "\t\t\t\t\t{{\n" + "\t\t\t\t\t{\n" '\t\t\t\t\t\t"bindings"\n' - "\t\t\t\t\t\t{{\n" + "\t\t\t\t\t\t{\n" '\t\t\t\t\t\t\t"binding"\t\t"controller_action empty_binding"\n' - "\t\t\t\t\t\t}}\n" - "\t\t\t\t\t}}\n" - "\t\t\t\t}}\n" - "\t\t\t}}".format(slot=slot) + "\t\t\t\t\t\t}\n" + "\t\t\t\t\t}\n" + "\t\t\t\t}\n" + "\t\t\t}" ) return text[:at] + block + text[at:] return None @@ -1220,8 +1218,8 @@ def _ensure_chord_config(steam_path, path, slot): return False nl = text.find("\n", idx) header = ( - "\t\"progenitor\"\t\t\"local://controller_base/chord_triton.vdf\"\n" - "\t\"url\"\t\t\"autosave://" + path + "\"\n" + '\t"progenitor"\t\t"local://controller_base/chord_triton.vdf"\n' + '\t"url"\t\t"autosave://' + path + '"\n' ) text = text[: nl + 1] + header + text[nl + 1 :] created = True @@ -1251,7 +1249,9 @@ def _ensure_chord_config(steam_path, path, slot): if modified: try: os.makedirs(os.path.dirname(path), exist_ok=True) - fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp") + fd, tmp = tempfile.mkstemp( + dir=os.path.dirname(path), suffix=".tmp" + ) try: with os.fdopen(fd, "w", encoding="utf-8", newline="") as f: f.write(text) diff --git a/windows/tests/applog/test_applog.py b/windows/tests/applog/test_applog.py index bcf35a7..9fcdf13 100644 --- a/windows/tests/applog/test_applog.py +++ b/windows/tests/applog/test_applog.py @@ -212,6 +212,7 @@ def test_parse_marker_rejects_malformed(tmpdir): # DualTouch-windows.exe, so _pid_is_trusted must accept BOTH image names # (same binary, two copies), not just the helper's own basename. + def test_allowed_image_names_frozen_pair(tmpdir): ch = _cursor_helper(tmpdir) names = ch._allowed_image_names(frozen=True) diff --git a/windows/tests/conftest.py b/windows/tests/conftest.py index 37d9749..c2a4f9b 100755 --- a/windows/tests/conftest.py +++ b/windows/tests/conftest.py @@ -30,3 +30,46 @@ import applog # noqa: E402 -- must import after the sys.path bootstrap above applog.set_logging_enabled(False) + + +# --- SceneRunner harness fixtures (tests/sc_runner.py) --------------- + +import time # noqa: E402 +from collections.abc import Iterator # noqa: E402 + +import pytest # noqa: E402 +import sc_runner # noqa: E402 +import steamcontroller.uinput as sui # noqa: E402 +from steamcontroller import events as sc_events # noqa: E402 +from triton import vkb as _vkb # noqa: E402 + + +@pytest.fixture +def runner(monkeypatch) -> Iterator[sc_runner.SceneRunner]: + """A headless OSK session with every OS-injection surface recorded. + + Patches, in order, BEFORE the ControllerManager is built: + • time.monotonic -> VirtualClock (hold-to-repeat cadence is exact); + • pynput Keyboard/Mouse -> recorders (vkb.kb, manager._kb/_mouse and + EventMapper's own keyboard all become recording instances sharing + one event stream). + """ + clock = sc_runner.VirtualClock() + monkeypatch.setattr(time, "monotonic", clock) + monkeypatch.setattr(sui, "Keyboard", sc_runner.RecordingKeyboard) + monkeypatch.setattr(sui, "Mouse", sc_runner.RecordingMouse) + monkeypatch.setattr(sc_events, "Keyboard", sc_runner.RecordingKeyboard) + monkeypatch.setattr(_vkb, "kb", sc_runner.RecordingKeyboard()) + yield sc_runner.SceneRunner(clock) + state_cleanup() + + +def state_cleanup(): + """Drop hooks/flags a test may have flipped so later tests start clean.""" + from triton import state + + state.set_key_sound(None) + state.set_haptic_tick(None) + state.set_pad_click_haptic(None) + state.set_key_sound_enabled(False) + state.reset_session() diff --git a/windows/tests/controller/test_chords_media.py b/windows/tests/controller/test_chords_media.py index 352d180..952719c 100644 --- a/windows/tests/controller/test_chords_media.py +++ b/windows/tests/controller/test_chords_media.py @@ -80,6 +80,7 @@ def test_media_volume_ramps_while_held(runner): runner.auto_advance = False runner.advance(0.02) runner.push(lstick=(0, -20000), buttons=btn) # edge: one step + def downs(): return sum( 1 @@ -89,7 +90,9 @@ def downs(): base = downs() assert base == 1 - for _ in range(22): # ~0.66 s held: past STICK_HOLD_DELAY (0.5 s), ramp is 21 ms + for _ in range( + 22 + ): # ~0.66 s held: past STICK_HOLD_DELAY (0.5 s), ramp is 21 ms runner.advance(0.03) runner.push(lstick=(0, -20000), buttons=btn) assert downs() > base @@ -100,9 +103,7 @@ def test_right_stick_moves_system_mouse(runner): for _ in range(12): runner.push(rstick=(20000, 0)) - moves = [ - e for e in sc_runner.RecordingMouse.EVENTS if e[0] == "move" - ] + moves = [e for e in sc_runner.RecordingMouse.EVENTS if e[0] == "move"] dxs = [e[1] for e in moves] assert dxs and all(d > 0 for d in dxs) # rightward drift accumulates @@ -112,9 +113,7 @@ def test_right_stick_deadzone_does_not_move_mouse(runner): for _ in range(12): runner.push(rstick=(3000, -3000)) # inside deadzone - assert not [ - e for e in sc_runner.RecordingMouse.EVENTS if e[0] == "move" - ] + assert not [e for e in sc_runner.RecordingMouse.EVENTS if e[0] == "move"] def test_right_stick_y_axis_inverts(runner): diff --git a/windows/tests/controller/test_pad_pointer.py b/windows/tests/controller/test_pad_pointer.py index 41e8aae..5275b8c 100644 --- a/windows/tests/controller/test_pad_pointer.py +++ b/windows/tests/controller/test_pad_pointer.py @@ -27,9 +27,7 @@ def test_settle_window_swallows_force_wobble(runner): letter = next( lbl for lbl in ("q", "w", "p", "y") - if diacritics.lookup_variants( - diacritics.DIACRITIC_VARIANTS, "en", lbl - ) + if diacritics.lookup_variants(diacritics.DIACRITIC_VARIANTS, "en", lbl) is None ) pos = runner.raw_at_cell(letter) @@ -65,9 +63,7 @@ def test_pad_force_click_path_inserts(runner): letter = next( lbl for lbl in ("q", "w", "p", "y") - if diacritics.lookup_variants( - diacritics.DIACRITIC_VARIANTS, "en", lbl - ) + if diacritics.lookup_variants(diacritics.DIACRITIC_VARIANTS, "en", lbl) is None ) pos = runner.raw_at_cell(letter) diff --git a/windows/tests/controller/test_select_flow.py b/windows/tests/controller/test_select_flow.py index 0022ead..1a2de6c 100644 --- a/windows/tests/controller/test_select_flow.py +++ b/windows/tests/controller/test_select_flow.py @@ -11,11 +11,7 @@ def _select_pos(runner): def _arrows(runner, name): - return [ - ev - for ev in runner.events() - if ev[0] == "down" and ev[1] == name - ] + return [ev for ev in runner.events() if ev[0] == "down" and ev[1] == name] def test_click_on_select_enters_select_mode(runner): @@ -35,16 +31,12 @@ def test_select_drag_fires_arrows_1to1(runner): # Drag right one SELECT_DRAG_STEP (0x1000) in 2 k frames. for i in range(1, 5): kw = {"lpad": (x + i * 0x400, 0)} - runner.push( - buttons=SCButtons.LB | SCButtons.LPADTOUCH, **kw - ) + runner.push(buttons=SCButtons.LB | SCButtons.LPADTOUCH, **kw) rights = len(_arrows(runner, "KEY_RIGHT")) assert rights == 1 # 0x1000 of travel exactly for i in range(5, 9): # another full step kw = {"lpad": (x + i * 0x400, 0)} - runner.push( - buttons=SCButtons.LB | SCButtons.LPADTOUCH, **kw - ) + runner.push(buttons=SCButtons.LB | SCButtons.LPADTOUCH, **kw) assert len(_arrows(runner, "KEY_RIGHT")) == 2 diff --git a/windows/tests/controller/test_session_lifecycle.py b/windows/tests/controller/test_session_lifecycle.py index 1c33232..ee0d560 100644 --- a/windows/tests/controller/test_session_lifecycle.py +++ b/windows/tests/controller/test_session_lifecycle.py @@ -22,9 +22,7 @@ def _seed_pointers(cstate): center = CoordFraction.from_absolute(643, 184) idle = VirtualPointer(_st.InputState.INACTIVE, center) - cstate.set_pointers( - idle, VirtualPointer(_st.InputState.INACTIVE, center) - ) + cstate.set_pointers(idle, VirtualPointer(_st.InputState.INACTIVE, center)) def test_update_ignores_non_input_status(runner): @@ -49,9 +47,7 @@ def test_first_frame_only_guards_pads(runner): m = controller.ControllerManager(runner.cstate) aim = runner.raw_at_cell("j") # Frame 1: aimed at 'j', but the pad section is skipped. - controller.update( - runner.src, sc_make_frame(0, aim), m - ) + controller.update(runner.src, sc_make_frame(0, aim), m) x, y = runner.cstate.get_pointers()[0].coord_frac.to_absolute() seeded = CoordFraction.from_absolute(643, 184).to_absolute() assert (round(x), round(y)) == (round(seeded[0]), round(seeded[1])) @@ -115,22 +111,15 @@ def test_release_held_drops_everything_os_side(runner): m._mouse_r_active = True # holds LEFT button (swapped) m._select_pad = int(SCButtons.LT) state.set_select_active(True) - m._deferred_base[int(SCButtons.LT)] = CoordFraction.from_absolute( - 10, 10 - ) + m._deferred_base[int(SCButtons.LT)] = CoordFraction.from_absolute(10, 10) m._a_deferred_cell = (3, 1) m.release_held() - ups = { - ev[1] for ev in sc_runner.RecordingKeyboard.EVENTS - if ev[0] == "up" - } + ups = {ev[1] for ev in sc_runner.RecordingKeyboard.EVENTS if ev[0] == "up"} assert "KEY_LEFTSHIFT" in ups # shift + select teardown release it assert "KEY_ENTER" in ups - mouse = [ - e for e in sc_runner.RecordingMouse.EVENTS if e[0] == "release" - ] + mouse = [e for e in sc_runner.RecordingMouse.EVENTS if e[0] == "release"] assert ("release", "right") in mouse assert ("release", "left") in mouse assert not m._shift_active and not m._enter_active @@ -169,9 +158,7 @@ def test_rumble_and_sound_flags_gate_feedback(runner): assert runner.sounds == 0 -@pytest.mark.parametrize( - "w,h", [(1286, 369), (1920, 540), (900, 300)] -) +@pytest.mark.parametrize("w,h", [(1286, 369), (1920, 540), (900, 300)]) def test_screen_dims_sync_geometry(w, h): try: set_dims(w, h) diff --git a/windows/tests/cursor_ctrl/test_cursor_ctrl.py b/windows/tests/cursor_ctrl/test_cursor_ctrl.py index 6dfccde..c9bab3e 100644 --- a/windows/tests/cursor_ctrl/test_cursor_ctrl.py +++ b/windows/tests/cursor_ctrl/test_cursor_ctrl.py @@ -86,10 +86,10 @@ class R: cmd = create[0] assert cmd[cmd.index("/TN") + 1] == "DualTouchCursor" tr = cmd[cmd.index("/TR") + 1] - if sys.frozen if hasattr(sys, "frozen") else False: + if getattr(sys, "frozen", False): assert "--cursor-helper --daemon --token" in tr else: - assert "cursor_helper.py\" --daemon --token" in tr + assert 'cursor_helper.py" --daemon --token' in tr assert cursor_ctrl._TOKEN in tr # The show marker is written so the daemon's first poll has work. assert _marker(_data_dir).exists() diff --git a/windows/tests/cursor_helper/test_cursor_helper.py b/windows/tests/cursor_helper/test_cursor_helper.py index 94b79cf..3f33ad1 100644 --- a/windows/tests/cursor_helper/test_cursor_helper.py +++ b/windows/tests/cursor_helper/test_cursor_helper.py @@ -55,9 +55,7 @@ def Process(_pid): def test_pid_is_trusted_rejects_junk(monkeypatch): import types - monkeypatch.setitem( - sys.modules, "psutil", types.ModuleType("psutil_fake") - ) + monkeypatch.setitem(sys.modules, "psutil", types.ModuleType("psutil_fake")) monkeypatch.setitem(sys.modules, "psutil", _FakePsutil) assert not cursor_helper._pid_is_trusted(0) assert not cursor_helper._pid_is_trusted(-5) diff --git a/windows/tests/inputsrc/test_hid_source.py b/windows/tests/inputsrc/test_hid_source.py index 7074e13..bea763b 100644 --- a/windows/tests/inputsrc/test_hid_source.py +++ b/windows/tests/inputsrc/test_hid_source.py @@ -2,6 +2,7 @@ (patched into inputsrc) — attach/poll/stale/facade-forwarding/teardown.""" import time +from typing import Any import pytest from sc_runner import make_frame @@ -45,7 +46,7 @@ def patch_driver(monkeypatch): yield -def _wait_live(src, timeout=3.0): +def _wait_live(src, timeout=3.0) -> Any: deadline = time.monotonic() + timeout while time.monotonic() < deadline: if src._live() is not None: diff --git a/windows/tests/inputsrc/test_inputsrc_merge.py b/windows/tests/inputsrc/test_inputsrc_merge.py index 1d8b0fd..0d8079c 100644 --- a/windows/tests/inputsrc/test_inputsrc_merge.py +++ b/windows/tests/inputsrc/test_inputsrc_merge.py @@ -54,9 +54,7 @@ def test_frame_has_activity_counts_analog_trigger_pull(): def test_merge_inputs_or_and_max(): - a = _frame( - buttons=SCButtons.A, ltrig=100, lpad=(10, 20), lstick=(500, 0) - ) + a = _frame(buttons=SCButtons.A, ltrig=100, lpad=(10, 20), lstick=(500, 0)) b = _frame( buttons=SCButtons.B, ltrig=300, @@ -76,9 +74,7 @@ def test_merge_inputs_or_and_max(): def test_merge_inputs_touch_priority(): # b touches the left pad while a doesn't -> b's pad coords win. a = _frame(lpad=(111, 222)) - b = _frame( - buttons=SCButtons.LPADTOUCH, lpad=(-1, -2) - ) + b = _frame(buttons=SCButtons.LPADTOUCH, lpad=(-1, -2)) m = inputsrc.merge_inputs(a, b) assert (m.lpad_x, m.lpad_y) == (-1, -2) # Both touching -> a wins ("a" is the tuned SC source by convention). @@ -116,9 +112,7 @@ def close(self): def test_merger_polls_merges_and_routes_haptics(monkeypatch): clock_t = 1000.0 - monkeypatch.setattr( - time, "monotonic", lambda: clock_t - ) + monkeypatch.setattr(time, "monotonic", lambda: clock_t) merger = inputsrc.InputMerger() quiet = _FakeSrc() loud = _FakeSrc(frames=[_frame(buttons=SCButtons.X)]) @@ -138,9 +132,7 @@ def test_merger_polls_merges_and_routes_haptics(monkeypatch): assert quiet.calls == [] # Past the activity window the fan-out falls back to every source. - monkeypatch.setattr( - time, "monotonic", lambda: clock_t + 2.0 - ) + monkeypatch.setattr(time, "monotonic", lambda: clock_t + 2.0) merger.haptic_click() assert ("click",) in quiet.calls diff --git a/windows/tests/sc_runner.py b/windows/tests/sc_runner.py index a2107c6..0ae7815 100644 --- a/windows/tests/sc_runner.py +++ b/windows/tests/sc_runner.py @@ -1,4 +1,4 @@ -"""SceneRunner — GdUnit4-style scene runner for the DualTouch OSK. +"""SceneRunner — GdUnit4-style scene runner for the DualTouch OSK. Drives the REAL production pipeline headlessly: every pushed frame flows through controller.update() -> ControllerManager.handle_input() and then @@ -12,7 +12,6 @@ • Layer 2 — synthetic input injection through SceneRunner (this file). """ - import steamcontroller.uinput as sui from steamcontroller import ( SCButtons, @@ -32,7 +31,7 @@ _Y_SCALE = -0x10000 / (6 / 5) -def raw_for_px(px, py, center_fraction_x=1 / 4): +def raw_for_px(px, py, center_fraction_x=1 / 4) -> tuple[int, int]: """Raw lpad_x/lpad_y values whose pointer lands on pixel (px, py).""" return ( int(round(((px / screen.width) - center_fraction_x) * _X_SCALE)), @@ -369,7 +368,7 @@ def raw_at_cell(self, label=None, *, rc=None, side="left"): rx, ry = raw_for_px(px, py, cfx) return {"lpad": (rx, ry)} if side == "left" else {"rpad": (rx, ry)} - def _raw_at_px(self, px, py): + def _raw_at_px(self, px, py) -> dict: """Left-pad raw values landing the pointer on an absolute pixel.""" return {"lpad": raw_for_px(px, py)} diff --git a/windows/tests/steam_shortcut/test_steam_shortcut.py b/windows/tests/steam_shortcut/test_steam_shortcut.py index 62bce89..7f49f55 100644 --- a/windows/tests/steam_shortcut/test_steam_shortcut.py +++ b/windows/tests/steam_shortcut/test_steam_shortcut.py @@ -328,9 +328,13 @@ def fake_chord(tmpdir, monkeypatch): ) # Isolate the state file from the real %APPDATA% one. monkeypatch.setattr( - "steam_shortcut.state_path", lambda: os.path.join(str(tmpdir), "state.json") + "steam_shortcut.state_path", + lambda: os.path.join(str(tmpdir), "state.json"), + ) + assert ( + os.path.join("controller_base", "chord_triton.vdf") + == _CHORD_BASE_TEMPLATE ) - assert os.path.join("controller_base", "chord_triton.vdf") == _CHORD_BASE_TEMPLATE return steam, ud, target @@ -344,7 +348,7 @@ def test_block_creates_per_user_config_when_missing(fake_chord): text = f.read() assert _braces_balanced(text) # The autosave header Steam writes for per-user configs. - assert 'progenitor' in text and 'autosave://' in text + assert "progenitor" in text and "autosave://" in text # The dead binding landed in the X slot (X blocks the Steam menu). assert '"controller_action empty_binding"' in text assert '"controller_action SHOW_KEYBOARD"' not in text diff --git a/windows/tests/tray/test_tray_menu_battery.py b/windows/tests/tray/test_tray_menu_battery.py index 88f4a8f..bb0e3b7 100644 --- a/windows/tests/tray/test_tray_menu_battery.py +++ b/windows/tests/tray/test_tray_menu_battery.py @@ -1,6 +1,7 @@ """Layer 1/2: tray battery notification matrix + menu wiring smoke.""" from threading import Event +from typing import Any from unittest.mock import MagicMock import pytest @@ -16,6 +17,9 @@ def __init__(self, percent, charging, charge_complete=False): class _Host(_BatteryMixin): + _battery: Any + _current_sc: Any + def __init__(self): self._stop_event = Event() self._battery = None @@ -102,9 +106,7 @@ def test_charging_never_warns_and_puck_suppresses_charged_toast( import steamcontroller as sc wired = sc.PRODUCT_ID_WIRED - monkeypatch.setattr( - sc, "present_product_ids", lambda: {wired}, False - ) + monkeypatch.setattr(sc, "present_product_ids", lambda: {wired}, False) h = _Host() h._battery_notifications(_Batt(10, True)) h._battery_notifications(_Batt(12, True)) diff --git a/windows/tests/vkb/test_diacritic_hold.py b/windows/tests/vkb/test_diacritic_hold.py index 7c50844..dfe5b8c 100644 --- a/windows/tests/vkb/test_diacritic_hold.py +++ b/windows/tests/vkb/test_diacritic_hold.py @@ -6,9 +6,7 @@ from steamcontroller import SCButtons from triton import diacritics, state -_EN_A = diacritics.lookup_variants( - diacritics.DIACRITIC_VARIANTS, "en", "a" -) +_EN_A = diacritics.lookup_variants(diacritics.DIACRITIC_VARIANTS, "en", "a") @pytest.fixture @@ -72,6 +70,7 @@ def test_hold_with_finger_off_strip_defaults_to_first(runner, en_a): def test_finger_x_picks_variant_on_release(runner, en_a): _hold_open_row(runner) rect = state.get_diacritic_rect() + assert rect is not None # the row was just opened n = state.get_diacritic_variant_count() slot = 2 px = rect[0] + rect[2] * (slot + 0.5) / n diff --git a/windows/tests/vptr/test_vptr.py b/windows/tests/vptr/test_vptr.py index bb69b9f..eca67bf 100644 --- a/windows/tests/vptr/test_vptr.py +++ b/windows/tests/vptr/test_vptr.py @@ -1,6 +1,5 @@ """Layer 1: VirtualPointer smoothing semantics (the pad-pointer low-pass).""" - from triton import state from triton.geometry import CoordFraction from triton.vptr import VirtualPointer From ab8a1ebd939dbada888288de2beb48b4a314dd58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 09:28:07 +0200 Subject: [PATCH 07/20] ci: pre-commit gate via mise; SHA-pin all actions in test workflow --- .github/workflows/test.yml | 51 ++++++++++ mise.lock | 190 +++++++++++++------------------------ mise.toml | 6 +- 3 files changed, 121 insertions(+), 126 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..84bc13c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,51 @@ +name: test + +on: + push: + branches: ["**"] + pull_request: + workflow_dispatch: + +jobs: + pytest: + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1 + with: + # Full history so pre-commit can diff against origin/main. + fetch-depth: 0 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install mise + shell: pwsh + run: | + Invoke-WebRequest https://mise.run.ps1 -UseBasicParsing | Invoke-Expression + Add-Content $env:GITHUB_PATH "$env:USERPROFILE\.local\bin" + + - name: Install dependencies + run: | + pip install --prefer-binary -r requirements.txt + pip install -r windows/requirements-dev.txt + + - name: Install tools (mise.toml pre-commit) + run: mise install + + - name: Run tests + working-directory: windows + env: + PYTEST_ADDOPTS: "--junitxml=test-results/junit.xml" + run: python -m pytest tests -q + + - name: Run pre-commit (origin/main..HEAD) + run: mise exec -- pre-commit run -av --from-ref origin/main --to-ref HEAD + + - name: Upload test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: junit-results + path: windows/test-results/junit.xml + if-no-files-found: warn diff --git a/mise.lock b/mise.lock index dabdb23..16f18a5 100755 --- a/mise.lock +++ b/mise.lock @@ -1,51 +1,42 @@ # @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html -[[tools."github:DetachHead/basedpyright"]] -version = "1.39.10" -backend = "github:DetachHead/basedpyright" - -[tools."github:DetachHead/basedpyright"."platforms.linux-arm64"] -checksum = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e" -url = "https://github.com/DetachHead/basedpyright/releases/download/v1.39.10/basedpyright-1.39.10.tar.gz" -url_api = "https://api.github.com/repos/DetachHead/basedpyright/releases/assets/513265626" -provenance = "github-attestations" - -[tools."github:DetachHead/basedpyright"."platforms.linux-arm64-musl"] -checksum = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e" -url = "https://github.com/DetachHead/basedpyright/releases/download/v1.39.10/basedpyright-1.39.10.tar.gz" -url_api = "https://api.github.com/repos/DetachHead/basedpyright/releases/assets/513265626" -provenance = "github-attestations" - -[tools."github:DetachHead/basedpyright"."platforms.linux-x64"] -checksum = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e" -url = "https://github.com/DetachHead/basedpyright/releases/download/v1.39.10/basedpyright-1.39.10.tar.gz" -url_api = "https://api.github.com/repos/DetachHead/basedpyright/releases/assets/513265626" -provenance = "github-attestations" - -[tools."github:DetachHead/basedpyright"."platforms.linux-x64-musl"] -checksum = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e" -url = "https://github.com/DetachHead/basedpyright/releases/download/v1.39.10/basedpyright-1.39.10.tar.gz" -url_api = "https://api.github.com/repos/DetachHead/basedpyright/releases/assets/513265626" -provenance = "github-attestations" - -[tools."github:DetachHead/basedpyright"."platforms.macos-arm64"] -checksum = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e" -url = "https://github.com/DetachHead/basedpyright/releases/download/v1.39.10/basedpyright-1.39.10.tar.gz" -url_api = "https://api.github.com/repos/DetachHead/basedpyright/releases/assets/513265626" -provenance = "github-attestations" - -[tools."github:DetachHead/basedpyright"."platforms.macos-x64"] -checksum = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e" -url = "https://github.com/DetachHead/basedpyright/releases/download/v1.39.10/basedpyright-1.39.10.tar.gz" -url_api = "https://api.github.com/repos/DetachHead/basedpyright/releases/assets/513265626" -provenance = "github-attestations" - -[tools."github:DetachHead/basedpyright"."platforms.windows-x64"] -checksum = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e" -url = "https://github.com/DetachHead/basedpyright/releases/download/v1.39.10/basedpyright-1.39.10.tar.gz" -url_api = "https://api.github.com/repos/DetachHead/basedpyright/releases/assets/513265626" -provenance = "github-attestations" -provenance_verified = true +[[tools."pipx:pre-commit"]] +version = "4.6.2" +backend = "pipx:pre-commit" + +[[tools.pre-commit]] +version = "4.6.2" +backend = "aqua:pre-commit/pre-commit" + +[tools.pre-commit."platforms.linux-arm64"] +checksum = "sha256:43c09a73d82af8efff09900e60d76d67a2c3d990613c3c770b2b5c4418a16c4b" +url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.2/pre-commit-4.6.2.pyz" +url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/509317714" + +[tools.pre-commit."platforms.linux-arm64-musl"] +checksum = "sha256:43c09a73d82af8efff09900e60d76d67a2c3d990613c3c770b2b5c4418a16c4b" +url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.2/pre-commit-4.6.2.pyz" +url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/509317714" + +[tools.pre-commit."platforms.linux-x64"] +checksum = "sha256:43c09a73d82af8efff09900e60d76d67a2c3d990613c3c770b2b5c4418a16c4b" +url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.2/pre-commit-4.6.2.pyz" +url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/509317714" + +[tools.pre-commit."platforms.linux-x64-musl"] +checksum = "sha256:43c09a73d82af8efff09900e60d76d67a2c3d990613c3c770b2b5c4418a16c4b" +url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.2/pre-commit-4.6.2.pyz" +url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/509317714" + +[tools.pre-commit."platforms.macos-arm64"] +checksum = "sha256:43c09a73d82af8efff09900e60d76d67a2c3d990613c3c770b2b5c4418a16c4b" +url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.2/pre-commit-4.6.2.pyz" +url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/509317714" + +[tools.pre-commit."platforms.macos-x64"] +checksum = "sha256:43c09a73d82af8efff09900e60d76d67a2c3d990613c3c770b2b5c4418a16c4b" +url = "https://github.com/pre-commit/pre-commit/releases/download/v4.6.2/pre-commit-4.6.2.pyz" +url_api = "https://api.github.com/repos/pre-commit/pre-commit/releases/assets/509317714" [[tools.prek]] version = "0.4.13" @@ -87,93 +78,48 @@ url = "https://github.com/j178/prek/releases/download/v0.4.13/prek-x86_64-apple- url_api = "https://api.github.com/repos/j178/prek/releases/assets/508529818" provenance = "github-attestations" -[tools.prek."platforms.windows-x64"] -checksum = "sha256:bf7adf70705a6ba0d2e1fd8a4016397e2609bc1765474eddec466db460a9f4ea" -url = "https://github.com/j178/prek/releases/download/v0.4.13/prek-x86_64-pc-windows-msvc.zip" -url_api = "https://api.github.com/repos/j178/prek/releases/assets/508529822" -provenance = "github-attestations" - -[[tools.python]] -version = "3.12.14" -backend = "core:python" - -[tools.python."platforms.linux-arm64"] -checksum = "sha256:2d8e17dfd732102cfeb18e0e1fa6769b24caa034e159981129590fe409c7157a" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" -provenance = "github-attestations" - -[tools.python."platforms.linux-arm64-musl"] -checksum = "sha256:4167705abc3e40767c7bd8f006ae5dbe677397391a2d9f7549e7759e3806180a" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-aarch64-unknown-linux-musl-install_only_stripped.tar.gz" -provenance = "github-attestations" - -[tools.python."platforms.linux-x64"] -checksum = "sha256:5acfa3e9ba26b51ae161c83aff278da915b590d22373a424b2ba55b8afe91fcc" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" -provenance = "github-attestations" - -[tools.python."platforms.linux-x64-musl"] -checksum = "sha256:25adbf9d865ada81c728ba9ee6cd042736b6b46dcd3a889198038da04eed9c4a" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-x86_64-unknown-linux-musl-install_only_stripped.tar.gz" -provenance = "github-attestations" - -[tools.python."platforms.macos-arm64"] -checksum = "sha256:dd5b76ab11451a4a4367c17c61d944dded56b425396b07f102922a7ebef7d55f" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-aarch64-apple-darwin-install_only_stripped.tar.gz" -provenance = "github-attestations" - -[tools.python."platforms.macos-x64"] -checksum = "sha256:aec265e3cddaccdb2a3d783331596351b24d4a63c97af0a38f75f643c9451de9" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-x86_64-apple-darwin-install_only_stripped.tar.gz" -provenance = "github-attestations" - -[tools.python."platforms.windows-x64"] -checksum = "sha256:89f18f6932917163b74339ebcec2645c8e47ae7f1c5f2ac37f2b4f4cf3beb647" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-x86_64-pc-windows-msvc-install_only_stripped.tar.gz" -provenance = "github-attestations" - -[[tools.ruff]] -version = "0.16.3" -backend = "aqua:astral-sh/ruff" +[[tools.uv]] +version = "0.12.5" +backend = "aqua:astral-sh/uv" -[tools.ruff."platforms.linux-arm64"] -checksum = "sha256:8319ba22f655e6efc086103486d7165bf0de73f71ff1c5f25ba580153ad05feb" -url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-aarch64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140238" +[tools.uv."platforms.linux-arm64"] +checksum = "sha256:8767a0e77f2cd45436401b1b42bf7e9ed5a4a91a74a5305d6fe93249d0f6dbc5" +url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/514850993" provenance = "github-attestations" -[tools.ruff."platforms.linux-arm64-musl"] -checksum = "sha256:8319ba22f655e6efc086103486d7165bf0de73f71ff1c5f25ba580153ad05feb" -url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-aarch64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140238" +[tools.uv."platforms.linux-arm64-musl"] +checksum = "sha256:8767a0e77f2cd45436401b1b42bf7e9ed5a4a91a74a5305d6fe93249d0f6dbc5" +url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/514850993" provenance = "github-attestations" -[tools.ruff."platforms.linux-x64"] -checksum = "sha256:d67c9b5949981698c48915abf65e0b3406ba9184ad73521cdf20a926bc889c73" -url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-x86_64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140356" +[tools.uv."platforms.linux-x64"] +checksum = "sha256:a4742988791c9aeae68c78150d6cba762062ad2a47e53738c2779d2b596bfcdb" +url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/514851105" provenance = "github-attestations" -[tools.ruff."platforms.linux-x64-musl"] -checksum = "sha256:d67c9b5949981698c48915abf65e0b3406ba9184ad73521cdf20a926bc889c73" -url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-x86_64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140356" +[tools.uv."platforms.linux-x64-musl"] +checksum = "sha256:a4742988791c9aeae68c78150d6cba762062ad2a47e53738c2779d2b596bfcdb" +url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/514851105" provenance = "github-attestations" -[tools.ruff."platforms.macos-arm64"] -checksum = "sha256:136a4db6512d9b16dda56ac8604696ed65c3b1a914a142de029e7f8d5006f1d9" -url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-aarch64-apple-darwin.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140218" +[tools.uv."platforms.macos-arm64"] +checksum = "sha256:5bb0e5fe008a773c3dbcb97ff79cd89e1241464fe9d2f986d52ad8f1b037bd62" +url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/514850968" provenance = "github-attestations" -[tools.ruff."platforms.macos-x64"] -checksum = "sha256:05c2a6705e7c0c056d6d93ff538978583f0c47b4c28d334ab9d58d2e8daf4c24" -url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-x86_64-apple-darwin.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140338" +[tools.uv."platforms.macos-x64"] +checksum = "sha256:b3b2137477cf96c9686ebfb71524614cec780c673fd73e59bce099aef02e70e8" +url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/514851078" provenance = "github-attestations" -[tools.ruff."platforms.windows-x64"] -checksum = "sha256:f10c709755b393fd9821506b21070bcca969b9966504edd1e490efd08e3662ba" -url = "https://github.com/astral-sh/ruff/releases/download/0.16.3/ruff-x86_64-pc-windows-msvc.zip" -url_api = "https://api.github.com/repos/astral-sh/ruff/releases/assets/513140346" +[tools.uv."platforms.windows-x64"] +checksum = "sha256:4c4d49d8738847d9b71ba319e49a5688c93eac0fe6204b1df24e98528dddf39a" +url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/514851084" provenance = "github-attestations" diff --git a/mise.toml b/mise.toml index 6ee1c1b..ba71cac 100755 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,3 @@ [tools] -python = "3.12" -prek = "0.4.13" -ruff = "0.16.3" -"github:DetachHead/basedpyright" = "1.39.10" \ No newline at end of file +uv = "latest" +"pipx:pre-commit" = "4.6.2" From 672facb7c4a9b3b680933d8c04dd9a45cb1ec6bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 09:48:54 +0200 Subject: [PATCH 08/20] ci: python 3.12 via mise (jdx/mise-action), pin uv 0.9.9 --- .github/workflows/build.yml | 6 ++- .github/workflows/test.yml | 17 +++----- mise.lock | 83 +++++++++++++++++++++++++++---------- mise.toml | 3 +- 4 files changed, 73 insertions(+), 36 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b0870e..6c3f5b1 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,8 +21,10 @@ jobs: # Full history so build.py can read the latest tag for the exe version. fetch-depth: 0 - - name: Set up mise - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + # mise provides python 3.12 (see mise.toml); action caches installs. + - uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac # v2.4.4 + with: + cache: true - name: Install dependencies run: python -m pip install --upgrade pip && python -m pip install --prefer-binary -r requirements.txt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 84bc13c..c20de39 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,19 +15,14 @@ jobs: # Full history so pre-commit can diff against origin/main. fetch-depth: 0 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.12" - - - name: Install mise - shell: pwsh - run: | - Invoke-WebRequest https://mise.run.ps1 -UseBasicParsing | Invoke-Expression - Add-Content $env:GITHUB_PATH "$env:USERPROFILE\.local\bin" + # Installs mise + everything in mise.toml (python 3.12, uv, + # pre-commit) and puts the shims on PATH for later steps. + - uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac # v2.4.4 - - name: Install dependencies + - name: Install python dependencies run: | - pip install --prefer-binary -r requirements.txt + python -m pip install --upgrade pip + python -m pip install --prefer-binary -r requirements.txt pip install -r windows/requirements-dev.txt - name: Install tools (mise.toml pre-commit) diff --git a/mise.lock b/mise.lock index 16f18a5..d8616fd 100755 --- a/mise.lock +++ b/mise.lock @@ -78,48 +78,87 @@ url = "https://github.com/j178/prek/releases/download/v0.4.13/prek-x86_64-apple- url_api = "https://api.github.com/repos/j178/prek/releases/assets/508529818" provenance = "github-attestations" +[[tools.python]] +version = "3.12.14" +backend = "core:python" + +[tools.python."platforms.linux-arm64"] +checksum = "sha256:2d8e17dfd732102cfeb18e0e1fa6769b24caa034e159981129590fe409c7157a" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.linux-arm64-musl"] +checksum = "sha256:4167705abc3e40767c7bd8f006ae5dbe677397391a2d9f7549e7759e3806180a" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-aarch64-unknown-linux-musl-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.linux-x64"] +checksum = "sha256:5acfa3e9ba26b51ae161c83aff278da915b590d22373a424b2ba55b8afe91fcc" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.linux-x64-musl"] +checksum = "sha256:25adbf9d865ada81c728ba9ee6cd042736b6b46dcd3a889198038da04eed9c4a" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-x86_64-unknown-linux-musl-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.macos-arm64"] +checksum = "sha256:dd5b76ab11451a4a4367c17c61d944dded56b425396b07f102922a7ebef7d55f" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-aarch64-apple-darwin-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.macos-x64"] +checksum = "sha256:aec265e3cddaccdb2a3d783331596351b24d4a63c97af0a38f75f643c9451de9" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-x86_64-apple-darwin-install_only_stripped.tar.gz" +provenance = "github-attestations" + +[tools.python."platforms.windows-x64"] +checksum = "sha256:89f18f6932917163b74339ebcec2645c8e47ae7f1c5f2ac37f2b4f4cf3beb647" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260814/cpython-3.12.14+20260814-x86_64-pc-windows-msvc-install_only_stripped.tar.gz" +provenance = "github-attestations" + [[tools.uv]] -version = "0.12.5" +version = "0.9.9" backend = "aqua:astral-sh/uv" [tools.uv."platforms.linux-arm64"] -checksum = "sha256:8767a0e77f2cd45436401b1b42bf7e9ed5a4a91a74a5305d6fe93249d0f6dbc5" -url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-aarch64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/514850993" +checksum = "sha256:84675e9f81c7ec45b11761db4e1f047c64e772d96a7d99201507a10279f7f835" +url = "https://github.com/astral-sh/uv/releases/download/0.9.9/uv-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/315634618" provenance = "github-attestations" [tools.uv."platforms.linux-arm64-musl"] -checksum = "sha256:8767a0e77f2cd45436401b1b42bf7e9ed5a4a91a74a5305d6fe93249d0f6dbc5" -url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-aarch64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/514850993" +checksum = "sha256:84675e9f81c7ec45b11761db4e1f047c64e772d96a7d99201507a10279f7f835" +url = "https://github.com/astral-sh/uv/releases/download/0.9.9/uv-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/315634618" provenance = "github-attestations" [tools.uv."platforms.linux-x64"] -checksum = "sha256:a4742988791c9aeae68c78150d6cba762062ad2a47e53738c2779d2b596bfcdb" -url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/514851105" +checksum = "sha256:b0e4530c1fed09720d7eb703703047f097fe23d6b266945a98422f0a0e5c74b4" +url = "https://github.com/astral-sh/uv/releases/download/0.9.9/uv-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/315634655" provenance = "github-attestations" [tools.uv."platforms.linux-x64-musl"] -checksum = "sha256:a4742988791c9aeae68c78150d6cba762062ad2a47e53738c2779d2b596bfcdb" -url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/514851105" +checksum = "sha256:b0e4530c1fed09720d7eb703703047f097fe23d6b266945a98422f0a0e5c74b4" +url = "https://github.com/astral-sh/uv/releases/download/0.9.9/uv-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/315634655" provenance = "github-attestations" [tools.uv."platforms.macos-arm64"] -checksum = "sha256:5bb0e5fe008a773c3dbcb97ff79cd89e1241464fe9d2f986d52ad8f1b037bd62" -url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-aarch64-apple-darwin.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/514850968" +checksum = "sha256:737e1c2c4f97577aa7764141846e27de915eebb3b2a0f467451089a64824d2f7" +url = "https://github.com/astral-sh/uv/releases/download/0.9.9/uv-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/315634607" provenance = "github-attestations" [tools.uv."platforms.macos-x64"] -checksum = "sha256:b3b2137477cf96c9686ebfb71524614cec780c673fd73e59bce099aef02e70e8" -url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-apple-darwin.tar.gz" -url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/514851078" +checksum = "sha256:8a755971dbf0b394cb61607c3d21e81affd62a22cfd306351eb518be7a3f0a89" +url = "https://github.com/astral-sh/uv/releases/download/0.9.9/uv-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/315634649" provenance = "github-attestations" [tools.uv."platforms.windows-x64"] -checksum = "sha256:4c4d49d8738847d9b71ba319e49a5688c93eac0fe6204b1df24e98528dddf39a" -url = "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-pc-windows-msvc.zip" -url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/514851084" +checksum = "sha256:b1c5d78c5cb6804e556e07eeafbcdea7378107c2bd5c342dea0cdc05a607d8e5" +url = "https://github.com/astral-sh/uv/releases/download/0.9.9/uv-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/315634651" provenance = "github-attestations" diff --git a/mise.toml b/mise.toml index ba71cac..4234cf7 100755 --- a/mise.toml +++ b/mise.toml @@ -1,3 +1,4 @@ [tools] -uv = "latest" +python = "3.12" +uv = "0.9.9" "pipx:pre-commit" = "4.6.2" From c44dd9cb8b7a77c4fabe6f433b42b9b469483e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 11:09:15 +0200 Subject: [PATCH 09/20] fix: reapply drain_input_work extraction + pointer-copy fix lost in rebase --- windows/triton/controller.py | 20 ++++-- windows/triton/triton.py | 115 +++++++++++++++++++---------------- 2 files changed, 79 insertions(+), 56 deletions(-) diff --git a/windows/triton/controller.py b/windows/triton/controller.py index 9ef1a93..c6b4490 100755 --- a/windows/triton/controller.py +++ b/windows/triton/controller.py @@ -843,9 +843,7 @@ def handle_input(self, sc, sc_input): adjust_raw_y(sc_input.lpad_y, 1 / 2), ) ptr_right_coords = CoordFraction.from_absolute( - adjust_raw_x_span( - sc_input.rpad_x, band_right, screen.width - ), + adjust_raw_x_span(sc_input.rpad_x, band_right, screen.width), adjust_raw_y(sc_input.rpad_y, 1 / 2), ) else: @@ -983,8 +981,20 @@ def handle_input(self, sc, sc_input): real_touch=rpad_touched, ) - ptr_left = vptr.VirtualPointer(input_state_left, ptr_left_coords) - ptr_right = vptr.VirtualPointer(input_state_right, ptr_right_coords) + # Fresh COPIES for the pointers: smoothen() mutates its coord in + # place, and ptr_*_coords may be ALIASED by items already queued on + # the controller thread (pad-click inserts / deferred bases) or by + # the stored lock targets. Without the copy the post-click glide + # would drag those queued coordinates off the key before the main + # thread drains them — the insert lands keys away from the lock. + ptr_left = vptr.VirtualPointer( + input_state_left, + CoordFraction.from_absolute(*ptr_left_coords.to_absolute()), + ) + ptr_right = vptr.VirtualPointer( + input_state_right, + CoordFraction.from_absolute(*ptr_right_coords.to_absolute()), + ) ptr_left.smoothen( self.prev_ptr_left, diff --git a/windows/triton/triton.py b/windows/triton/triton.py index 6bdaaf0..82b0110 100755 --- a/windows/triton/triton.py +++ b/windows/triton/triton.py @@ -199,6 +199,63 @@ def _flush_position_persist(now=None): pass +def drain_input_work(controller_state, virtual_kb): + """Drain controller-queued input into keyboard work. + + Shared by main()'s loop and the test SceneRunner so both drive the + exact same production path: DPAD steps (variant-row aware), the pad + click queue, and queued key presses (hold-to-extend / auto-repeat + / silent-deferred semantics included).""" + # DPAD: while a variant row is open, left/right moves the + # highlighted variant (the row owns the DPAD then); otherwise step + # the cursor using the actual layout pixel positions. + for direction, haptic in state.drain_dpad_queue(): + if state.is_diacritic_open(): + if direction in ("LEFT", "RIGHT"): + state.set_diacritic_index( + diacritics.step_variant_index( + state.get_diacritic_index(), + 1 if direction == "RIGHT" else -1, + state.get_diacritic_variant_count(), + ) + ) + continue + vkb.step_cursor(virtual_kb, direction, haptic=haptic) + vkb.process_click_queue(virtual_kb, controller_state.click_queue) + # Key presses: fire the callback of the queued key. A repeat hit + # (something held) only fires over a repeatable key (Backspace / + # arrows), so holding rubs out / steps without machine-gunning + # ordinary keys. + for ( + row, + col, + is_repeat, + is_silent, + ) in state.drain_key_press_queue(): + if 0 <= row < len(virtual_kb.keys) and 0 <= col < len( + virtual_kb.keys[row] + ): + key = virtual_kb.keys[row][col] + if is_repeat and not vkb.is_repeatable(key): + # Hold-to-extend (Feature B): a held A over a letter + # opens its variant row on the first repeat (the base + # already fired on the press edge); A-release commits. + if vkb.diacritic_variants_for_key(key): + vkb.open_diacritic_rc(virtual_kb, row, col, "a") + continue + # Tell dispatch_key this is an auto-repeat so the key-press + # sound doesn't machine-gun on held keys; a deferred + # release (base of a variant key typed on release) is + # silent — its press edge already clicked. + vkb._dispatch_is_repeat = is_repeat + vkb._dispatch_silent = is_silent + try: + vkb.dispatch_key(virtual_kb, key) + finally: + vkb._dispatch_is_repeat = False + vkb._dispatch_silent = False + + def main(cached_screen=None, on_close=None): """Run the OSK until it closes. @@ -828,25 +885,12 @@ def main(cached_screen=None, on_close=None): scr._anim_target = None virtual_kb.update_dimensions() scr._resize_dirty = True - # --- Input-driven work runs EVERY loop iteration (NOT gated by the - # render rate), so cursor steps and key presses drain at low latency - # (the SC's frames go straight to the input thread). - # DPAD: while a variant row is open, left/right moves the - # highlighted variant (the row owns the DPAD then); otherwise step - # the cursor using the actual layout pixel positions. - for direction, haptic in state.drain_dpad_queue(): - if state.is_diacritic_open(): - if direction in ("LEFT", "RIGHT"): - state.set_diacritic_index( - diacritics.step_variant_index( - state.get_diacritic_index(), - 1 if direction == "RIGHT" else -1, - state.get_diacritic_variant_count(), - ) - ) - continue - vkb.step_cursor(virtual_kb, direction, haptic=haptic) - vkb.process_click_queue(virtual_kb, controller_state.click_queue) + # --- Input-driven work runs EVERY loop iteration (NOT gated by + # the render rate), so cursor steps and key presses drain at low + # latency (the SC's frames go straight to the input thread). The + # shared drain (DPAD + click queue + key presses) lives in + # drain_input_work so the test harness exercises the same path. + drain_input_work(controller_state, virtual_kb) # Mouse left-button hold-to-repeat: while held over a repeatable # key (Backspace / arrows), re-queue it on the shared cadence. # Queued before the drain so it dispatches this same frame. @@ -870,38 +914,7 @@ def main(cached_screen=None, on_close=None): mouse_repeat_at = float("inf") else: mouse_repeat_at = float("inf") - # Key presses: fire the callback of the queued key. A repeat hit - # (something held) only fires over a repeatable key (Backspace / - # arrows), so holding rubs out / steps without machine-gunning - # ordinary keys. - for ( - row, - col, - is_repeat, - is_silent, - ) in state.drain_key_press_queue(): - if 0 <= row < len(virtual_kb.keys) and 0 <= col < len( - virtual_kb.keys[row] - ): - key = virtual_kb.keys[row][col] - if is_repeat and not vkb.is_repeatable(key): - # Hold-to-extend (Feature B): a held A over a letter - # opens its variant row on the first repeat (the base - # already fired on the press edge); A-release commits. - if vkb.diacritic_variants_for_key(key): - vkb.open_diacritic_rc(virtual_kb, row, col, "a") - continue - # Tell dispatch_key this is an auto-repeat so the key-press - # sound doesn't machine-gun on held keys; a deferred - # release (base of a variant key typed on release) is - # silent — its press edge already clicked. - vkb._dispatch_is_repeat = is_repeat - vkb._dispatch_silent = is_silent - try: - vkb.dispatch_key(virtual_kb, key) - finally: - vkb._dispatch_is_repeat = False - vkb._dispatch_silent = False + if state.take_position_cycle_request(): _cycle_window_position(scr.window) _persist_position_for_app(_position_index[0]) From da8ccf07305d6ae6b4bdd12448d10a811b3a5688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 11:28:12 +0200 Subject: [PATCH 10/20] ci: fold pre-commit gate into tests.yml, drop duplicate test workflow; docs: restore README Testing section --- .github/workflows/build.yml | 2 +- .github/workflows/test.yml | 46 ------------------------------------- .github/workflows/tests.yml | 3 +++ README.md | 22 ++++++++++++++++++ 4 files changed, 26 insertions(+), 47 deletions(-) delete mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6c3f5b1..ef6ede3 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 # mise provides python 3.12 (see mise.toml); action caches installs. - - uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac # v2.4.4 + - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 with: cache: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index c20de39..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: test - -on: - push: - branches: ["**"] - pull_request: - workflow_dispatch: - -jobs: - pytest: - runs-on: windows-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1 - with: - # Full history so pre-commit can diff against origin/main. - fetch-depth: 0 - - # Installs mise + everything in mise.toml (python 3.12, uv, - # pre-commit) and puts the shims on PATH for later steps. - - uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac # v2.4.4 - - - name: Install python dependencies - run: | - python -m pip install --upgrade pip - python -m pip install --prefer-binary -r requirements.txt - pip install -r windows/requirements-dev.txt - - - name: Install tools (mise.toml pre-commit) - run: mise install - - - name: Run tests - working-directory: windows - env: - PYTEST_ADDOPTS: "--junitxml=test-results/junit.xml" - run: python -m pytest tests -q - - - name: Run pre-commit (origin/main..HEAD) - run: mise exec -- pre-commit run -av --from-ref origin/main --to-ref HEAD - - - name: Upload test results - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 - with: - name: junit-results - path: windows/test-results/junit.xml - if-no-files-found: warn diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 19b0293..fd041b7 100755 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,6 +29,9 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install --prefer-binary -r ../requirements.txt -r requirements-dev.txt + - name: Pre-commit (origin/main..HEAD) + run: mise exec -- pre-commit run -av --from-ref origin/main --to-ref HEAD + - name: Run tests id: run-tests working-directory: windows diff --git a/README.md b/README.md index fbbb0ef..7d5c433 100755 --- a/README.md +++ b/README.md @@ -136,6 +136,28 @@ Quitting from the tray never dispatches the `/0` restore; if the appid changed another way, relaunch and open/close the keyboard once (or alt-tab) to make Steam re-evaluate. +## Testing + +The OSK has a headless UI-test harness (a GdUnit4-style SceneRunner) that +drives the real input pipeline — controller frame parsing, the pad/click +state machines, hold-to-repeat cadence and the variant-row defer model — +with a virtual clock, recording every key/mouse injection at the OS +boundary. No display, Steam, or controller required: + +```sh +cd windows +python -m pytest tests -q # whole suite (fast, headless) +python -m pytest tests/test_diacritic_hold.py -q # one layer-2 module +``` + +Layers: pure state-machine tests (`test_nav_cursor`), synthetic-input +injection through `ControllerManager` + the shared drain loop +(`tests/sc_runner.py`, used by `test_typing_flow`, `test_diacritic_hold`, +`test_pad_pointer`). CI runs the same suite on every push via +`.github/workflows/test.yml` (JUnit XML artifact). Known OS-level +injection quirks on real hardware (pynput/SendInput desync) are out of +scope for this harness — those need a physical device. + ## Development Notes This project was built through AI-assisted development: a human set the From 86a51c7a4e1a1d73a9becf02f27a8c93b8f4d624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 13:11:04 +0200 Subject: [PATCH 11/20] refactor: rename test harness sc_runner/SceneRunner -> osk_sim/OskSim; fix E741 in split-layout tests --- README.md | 4 ++-- windows/tests/config/test_config.py | 16 +++++++-------- windows/tests/conftest.py | 20 +++++++++---------- windows/tests/controller/test_chords_media.py | 12 +++++------ windows/tests/controller/test_select_flow.py | 2 +- .../controller/test_session_lifecycle.py | 10 +++++----- windows/tests/inputsrc/test_hid_source.py | 2 +- windows/tests/{sc_runner.py => osk_sim.py} | 6 +++--- windows/tests/watchers/test_watchers.py | 2 +- 9 files changed, 37 insertions(+), 37 deletions(-) rename windows/tests/{sc_runner.py => osk_sim.py} (98%) diff --git a/README.md b/README.md index 7d5c433..ad0fbd4 100755 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ to make Steam re-evaluate. ## Testing -The OSK has a headless UI-test harness (a GdUnit4-style SceneRunner) that +The OSK has a headless UI-test harness (an input-pipeline simulator (OskSim)) that drives the real input pipeline — controller frame parsing, the pad/click state machines, hold-to-repeat cadence and the variant-row defer model — with a virtual clock, recording every key/mouse injection at the OS @@ -152,7 +152,7 @@ python -m pytest tests/test_diacritic_hold.py -q # one layer-2 module Layers: pure state-machine tests (`test_nav_cursor`), synthetic-input injection through `ControllerManager` + the shared drain loop -(`tests/sc_runner.py`, used by `test_typing_flow`, `test_diacritic_hold`, +(`tests/osk_sim.py`, used by `test_typing_flow`, `test_diacritic_hold`, `test_pad_pointer`). CI runs the same suite on every push via `.github/workflows/test.yml` (JUnit XML artifact). Known OS-level injection quirks on real hardware (pynput/SendInput desync) are out of diff --git a/windows/tests/config/test_config.py b/windows/tests/config/test_config.py index 0756963..1a88fd8 100644 --- a/windows/tests/config/test_config.py +++ b/windows/tests/config/test_config.py @@ -221,12 +221,12 @@ def test_split_layout_splits_rows_into_two_halves_with_gap(): assert band is not None band_left, band_right = band for i_row in range(kb.key_rows): - row_lays = [l for l in layouts if l.row == i_row] + row_lays = [lay for lay in layouts if lay.row == i_row] if len(row_lays) < 2: continue split_idx = kb._split_index(i_row) - left = [l for l in row_lays if l.col < split_idx] - right = [l for l in row_lays if l.col >= split_idx] + left = [lay for lay in row_lays if lay.col < split_idx] + right = [lay for lay in row_lays if lay.col >= split_idx] assert left and right, "split layout must leave keys on both sides" # Left half hugs the left edge; right half hugs the right edge. assert left[0].x <= screen.width // 2 @@ -234,8 +234,8 @@ def test_split_layout_splits_rows_into_two_halves_with_gap(): # No left key crosses into the right half and vice versa (the # halves may each reach past center when a wide key like Space # dominates its side — the gap between them is the invariant). - left_max = max(l.x + l.w for l in left) - right_min = min(l.x for l in right) + left_max = max(lay.x + lay.w for lay in left) + right_min = min(lay.x for lay in right) assert left_max < right_min # Every row's gap sits on the SAME band (rounding-tolerant: each # key's width is rounded, so error grows with key count), which is @@ -245,12 +245,12 @@ def test_split_layout_splits_rows_into_two_halves_with_gap(): assert abs(right_min - band_right) <= tol # The halves of every row are separated by a real (px) gap. for i_row in range(kb.key_rows): - row_lays = [l for l in layouts if l.row == i_row] + row_lays = [lay for lay in layouts if lay.row == i_row] if len(row_lays) < 2: continue split_idx = kb._split_index(i_row) - left_max = max(l.x + l.w for l in row_lays if l.col < split_idx) - right_min = min(l.x for l in row_lays if l.col >= split_idx) + left_max = max(lay.x + lay.w for lay in row_lays if lay.col < split_idx) + right_min = min(lay.x for lay in row_lays if lay.col >= split_idx) assert right_min - left_max >= gap - 4 # rounding-tolerant # Hit-testing: a click on the left half finds a LEFT-half key, a diff --git a/windows/tests/conftest.py b/windows/tests/conftest.py index c2a4f9b..020e75c 100755 --- a/windows/tests/conftest.py +++ b/windows/tests/conftest.py @@ -18,7 +18,7 @@ sys.path.insert(0, _ROOT) # Tests live in per-module subdirs (tests//...), so pytest's # per-file sys.path insertion no longer exposes tests/ itself — add it -# explicitly for the shared harness lib (sc_runner). +# explicitly for the shared harness lib (osk_sim). _TESTS = os.path.dirname(os.path.abspath(__file__)) if _TESTS not in sys.path: sys.path.insert(0, _TESTS) @@ -32,20 +32,20 @@ applog.set_logging_enabled(False) -# --- SceneRunner harness fixtures (tests/sc_runner.py) --------------- +# --- OskSim harness fixtures (tests/osk_sim.py) --------------- import time # noqa: E402 from collections.abc import Iterator # noqa: E402 +import osk_sim # noqa: E402 import pytest # noqa: E402 -import sc_runner # noqa: E402 import steamcontroller.uinput as sui # noqa: E402 from steamcontroller import events as sc_events # noqa: E402 from triton import vkb as _vkb # noqa: E402 @pytest.fixture -def runner(monkeypatch) -> Iterator[sc_runner.SceneRunner]: +def runner(monkeypatch) -> Iterator[osk_sim.OskSim]: """A headless OSK session with every OS-injection surface recorded. Patches, in order, BEFORE the ControllerManager is built: @@ -54,13 +54,13 @@ def runner(monkeypatch) -> Iterator[sc_runner.SceneRunner]: EventMapper's own keyboard all become recording instances sharing one event stream). """ - clock = sc_runner.VirtualClock() + clock = osk_sim.VirtualClock() monkeypatch.setattr(time, "monotonic", clock) - monkeypatch.setattr(sui, "Keyboard", sc_runner.RecordingKeyboard) - monkeypatch.setattr(sui, "Mouse", sc_runner.RecordingMouse) - monkeypatch.setattr(sc_events, "Keyboard", sc_runner.RecordingKeyboard) - monkeypatch.setattr(_vkb, "kb", sc_runner.RecordingKeyboard()) - yield sc_runner.SceneRunner(clock) + monkeypatch.setattr(sui, "Keyboard", osk_sim.RecordingKeyboard) + monkeypatch.setattr(sui, "Mouse", osk_sim.RecordingMouse) + monkeypatch.setattr(sc_events, "Keyboard", osk_sim.RecordingKeyboard) + monkeypatch.setattr(_vkb, "kb", osk_sim.RecordingKeyboard()) + yield osk_sim.OskSim(clock) state_cleanup() diff --git a/windows/tests/controller/test_chords_media.py b/windows/tests/controller/test_chords_media.py index 952719c..c85e1dc 100644 --- a/windows/tests/controller/test_chords_media.py +++ b/windows/tests/controller/test_chords_media.py @@ -99,27 +99,27 @@ def downs(): def test_right_stick_moves_system_mouse(runner): - import sc_runner + import osk_sim for _ in range(12): runner.push(rstick=(20000, 0)) - moves = [e for e in sc_runner.RecordingMouse.EVENTS if e[0] == "move"] + moves = [e for e in osk_sim.RecordingMouse.EVENTS if e[0] == "move"] dxs = [e[1] for e in moves] assert dxs and all(d > 0 for d in dxs) # rightward drift accumulates def test_right_stick_deadzone_does_not_move_mouse(runner): - import sc_runner + import osk_sim for _ in range(12): runner.push(rstick=(3000, -3000)) # inside deadzone - assert not [e for e in sc_runner.RecordingMouse.EVENTS if e[0] == "move"] + assert not [e for e in osk_sim.RecordingMouse.EVENTS if e[0] == "move"] def test_right_stick_y_axis_inverts(runner): - import sc_runner + import osk_sim for _ in range(12): runner.push(rstick=(0, 20000)) # stick up - dys = [e[2] for e in sc_runner.RecordingMouse.EVENTS if e[0] == "move"] + dys = [e[2] for e in osk_sim.RecordingMouse.EVENTS if e[0] == "move"] assert dys and all(d < 0 for d in dys) # screen y shrinks diff --git a/windows/tests/controller/test_select_flow.py b/windows/tests/controller/test_select_flow.py index 1a2de6c..3757be7 100644 --- a/windows/tests/controller/test_select_flow.py +++ b/windows/tests/controller/test_select_flow.py @@ -1,4 +1,4 @@ -"""Layer 2: the Select-key text-selection session at SceneRunner level — +"""Layer 2: the Select-key text-selection session at OskSim level — enter via pad click, drag arrows, freeze on finger-lift, roll-back cancel on release, and clean teardown.""" diff --git a/windows/tests/controller/test_session_lifecycle.py b/windows/tests/controller/test_session_lifecycle.py index ee0d560..3155adf 100644 --- a/windows/tests/controller/test_session_lifecycle.py +++ b/windows/tests/controller/test_session_lifecycle.py @@ -11,7 +11,7 @@ def _raw_frame(status): - from sc_runner import make_frame + from osk_sim import make_frame return make_frame(buttons=SCButtons.A)._replace(status=status) @@ -60,7 +60,7 @@ def test_first_frame_only_guards_pads(runner): def sc_make_frame(_buttons, kw): - from sc_runner import make_frame + from osk_sim import make_frame return make_frame(**kw) @@ -100,7 +100,7 @@ def test_reopen_drains_stale_click_queue(): def test_release_held_drops_everything_os_side(runner): - import sc_runner + import osk_sim m = runner.manager @@ -116,10 +116,10 @@ def test_release_held_drops_everything_os_side(runner): m.release_held() - ups = {ev[1] for ev in sc_runner.RecordingKeyboard.EVENTS if ev[0] == "up"} + ups = {ev[1] for ev in osk_sim.RecordingKeyboard.EVENTS if ev[0] == "up"} assert "KEY_LEFTSHIFT" in ups # shift + select teardown release it assert "KEY_ENTER" in ups - mouse = [e for e in sc_runner.RecordingMouse.EVENTS if e[0] == "release"] + mouse = [e for e in osk_sim.RecordingMouse.EVENTS if e[0] == "release"] assert ("release", "right") in mouse assert ("release", "left") in mouse assert not m._shift_active and not m._enter_active diff --git a/windows/tests/inputsrc/test_hid_source.py b/windows/tests/inputsrc/test_hid_source.py index bea763b..2421bbd 100644 --- a/windows/tests/inputsrc/test_hid_source.py +++ b/windows/tests/inputsrc/test_hid_source.py @@ -5,7 +5,7 @@ from typing import Any import pytest -from sc_runner import make_frame +from osk_sim import make_frame from triton import inputsrc, state diff --git a/windows/tests/sc_runner.py b/windows/tests/osk_sim.py similarity index 98% rename from windows/tests/sc_runner.py rename to windows/tests/osk_sim.py index 0ae7815..043849d 100644 --- a/windows/tests/sc_runner.py +++ b/windows/tests/osk_sim.py @@ -1,4 +1,4 @@ -"""SceneRunner — GdUnit4-style scene runner for the DualTouch OSK. +"""OskSim — scene runner for the DualTouch OSK. Drives the REAL production pipeline headlessly: every pushed frame flows through controller.update() -> ControllerManager.handle_input() and then @@ -9,7 +9,7 @@ Layers built on this module: • Layer 1 — pure state-machine tests (vkb.step_cursor etc.), no runner. - • Layer 2 — synthetic input injection through SceneRunner (this file). + • Layer 2 — synthetic input injection through OskSim (this file). """ import steamcontroller.uinput as sui @@ -223,7 +223,7 @@ def make_frame( ) -class SceneRunner: +class OskSim: """One headless OSK session driven frame-by-frame.""" def __init__(self, clock=None): diff --git a/windows/tests/watchers/test_watchers.py b/windows/tests/watchers/test_watchers.py index c4b4bb8..4178733 100644 --- a/windows/tests/watchers/test_watchers.py +++ b/windows/tests/watchers/test_watchers.py @@ -13,7 +13,7 @@ def addExit(self): def _frame(buttons, status=66): - from sc_runner import make_frame as _mf + from osk_sim import make_frame as _mf return _mf(buttons=buttons)._replace(status=status) From d4f60a723352c0af1803bd529e68ed595c41d061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 13:31:47 +0200 Subject: [PATCH 12/20] ci: fetch full history so pre-commit can diff origin/main..HEAD --- .github/workflows/tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fd041b7..de25cae 100755 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,6 +20,9 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history so pre-commit can diff origin/main..HEAD. + fetch-depth: 0 - name: Set up mise id: setup-mise uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 From 4340e35e15a87b68ddc624710181eacf779b4c34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 14:05:26 +0200 Subject: [PATCH 13/20] style: ruff-format split-layout test line (CI gate) --- windows/tests/config/test_config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/windows/tests/config/test_config.py b/windows/tests/config/test_config.py index 1a88fd8..97d972b 100644 --- a/windows/tests/config/test_config.py +++ b/windows/tests/config/test_config.py @@ -249,7 +249,9 @@ def test_split_layout_splits_rows_into_two_halves_with_gap(): if len(row_lays) < 2: continue split_idx = kb._split_index(i_row) - left_max = max(lay.x + lay.w for lay in row_lays if lay.col < split_idx) + left_max = max( + lay.x + lay.w for lay in row_lays if lay.col < split_idx + ) right_min = min(lay.x for lay in row_lays if lay.col >= split_idx) assert right_min - left_max >= gap - 4 # rounding-tolerant From 2f9e09176cbc758e1ad4d2639dafdd2b49052f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 14:08:07 +0200 Subject: [PATCH 14/20] ci: move pre-commit gate to its own ubuntu job --- .github/workflows/tests.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index de25cae..b05dcc4 100755 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,6 +14,21 @@ permissions: pull-requests: write jobs: + lint: + # Platform-neutral gates (ruff, ruff-format, basedpyright, hygiene) + # on the cheap runner; pytest keeps its own Windows job below. + name: pre-commit + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history so pre-commit can diff origin/main..HEAD. + fetch-depth: 0 + - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + - name: Pre-commit (origin/main..HEAD) + run: mise exec -- pre-commit run -av --from-ref origin/main --to-ref HEAD + tests: name: tests runs-on: windows-latest @@ -32,9 +47,6 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install --prefer-binary -r ../requirements.txt -r requirements-dev.txt - - name: Pre-commit (origin/main..HEAD) - run: mise exec -- pre-commit run -av --from-ref origin/main --to-ref HEAD - - name: Run tests id: run-tests working-directory: windows From 5b5a567fe0f4a02526b1699f6109c53f7c148fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 14:22:15 +0200 Subject: [PATCH 15/20] fix: hermetic skin-selection test (fake Steam themes, no live-install dependence) --- windows/tests/color/test_color_skins.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/windows/tests/color/test_color_skins.py b/windows/tests/color/test_color_skins.py index 3174e11..860fb01 100644 --- a/windows/tests/color/test_color_skins.py +++ b/windows/tests/color/test_color_skins.py @@ -31,8 +31,12 @@ def test_available_skins_include_bundled(): assert any("gruvbox" in n.lower() for n in names) -def test_skin_selection_roundtrip(): +def test_skin_selection_roundtrip(monkeypatch): + # Hermetic: pretend Steam carries two extra themes so there is always + # something to switch to, regardless of the machine's Steam install. + monkeypatch.setattr(skins, "_steam_theme_names", lambda: {"Alpha", "Beta"}) names = skins.available_skins() + assert "Gruvbox" in names and "Alpha" in names # appended after order other = next( n for n in names if n.lower() != skins.get_active_skin().lower() ) From 66823fa307ce0c81d3fe574c3f442505be71c8a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 14:31:53 +0200 Subject: [PATCH 16/20] ci: install runtime deps in lint job so basedpyright resolves imports --- .github/workflows/tests.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b05dcc4..e1fff97 100755 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,6 +26,11 @@ jobs: # Full history so pre-commit can diff origin/main..HEAD. fetch-depth: 0 - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + # Real deps so basedpyright resolves pytest/vdf/PIL/nuitka imports. + - name: Install python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --prefer-binary -r windows/requirements.txt -r windows/requirements-dev.txt - name: Pre-commit (origin/main..HEAD) run: mise exec -- pre-commit run -av --from-ref origin/main --to-ref HEAD From 7503372de59cfbbae9f16bb80e573a5a7ed50fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 14:34:58 +0200 Subject: [PATCH 17/20] ci: fix requirements paths in lint job (root requirements.txt) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e1fff97..4f672a2 100755 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,7 +30,7 @@ jobs: - name: Install python dependencies run: | python -m pip install --upgrade pip - python -m pip install --prefer-binary -r windows/requirements.txt -r windows/requirements-dev.txt + python -m pip install --prefer-binary -r requirements.txt -r windows/requirements-dev.txt - name: Pre-commit (origin/main..HEAD) run: mise exec -- pre-commit run -av --from-ref origin/main --to-ref HEAD From fe50bd762d42db5c928fab23fff55969a284c8d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 14:44:27 +0200 Subject: [PATCH 18/20] ci: install cairo/girepository headers so PyGObject builds on the linux lint job --- .github/workflows/tests.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4f672a2..2e59d2b 100755 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,6 +27,11 @@ jobs: fetch-depth: 0 - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 # Real deps so basedpyright resolves pytest/vdf/PIL/nuitka imports. + - name: Install system deps (PyGObject build on linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libcairo2-dev libgirepository1.0-dev pkg-config - name: Install python dependencies run: | python -m pip install --upgrade pip From 8e8c417c0bef8d6db2bdb5bde1b34a937b4b10cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 14:45:07 +0200 Subject: [PATCH 19/20] =?UTF-8?q?ci:=20run=20pre-commit=20job=20on=20windo?= =?UTF-8?q?ws=20too=20=E2=80=94=20matches=20the=20platform=20the=20code=20?= =?UTF-8?q?targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/tests.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2e59d2b..17889c0 100755 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,7 +18,7 @@ jobs: # Platform-neutral gates (ruff, ruff-format, basedpyright, hygiene) # on the cheap runner; pytest keeps its own Windows job below. name: pre-commit - runs-on: ubuntu-latest + runs-on: windows-latest timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -27,11 +27,6 @@ jobs: fetch-depth: 0 - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 # Real deps so basedpyright resolves pytest/vdf/PIL/nuitka imports. - - name: Install system deps (PyGObject build on linux) - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y libcairo2-dev libgirepository1.0-dev pkg-config - name: Install python dependencies run: | python -m pip install --upgrade pip From 0f762fceff481155f7e20337e4b4e15880dc427a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20K=C5=82ysz?= Date: Mon, 24 Aug 2026 14:50:36 +0200 Subject: [PATCH 20/20] =?UTF-8?q?ci:=20silence=20reportMissingImports=20?= =?UTF-8?q?=E2=80=94=20missing=20deps=20are=20caught=20by=20the=20pytest?= =?UTF-8?q?=20suite=20itself?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/tests.yml | 5 ----- pyrightconfig.json | 6 ++++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 17889c0..074f57d 100755 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,11 +26,6 @@ jobs: # Full history so pre-commit can diff origin/main..HEAD. fetch-depth: 0 - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 - # Real deps so basedpyright resolves pytest/vdf/PIL/nuitka imports. - - name: Install python dependencies - run: | - python -m pip install --upgrade pip - python -m pip install --prefer-binary -r requirements.txt -r windows/requirements-dev.txt - name: Pre-commit (origin/main..HEAD) run: mise exec -- pre-commit run -av --from-ref origin/main --to-ref HEAD diff --git a/pyrightconfig.json b/pyrightconfig.json index ce0efa7..0d184ce 100755 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -6,7 +6,9 @@ "root": "windows" } ], - "include": ["windows"], + "include": [ + "windows" + ], "reportMissingTypeStubs": "none", "reportMissingParameterType": "none", "reportUnknownMemberType": "none", @@ -25,6 +27,6 @@ "reportUnusedVariable": "none", "reportPrivateUsage": "none", "reportUnusedImport": "none", - "reportMissingImports": "warning", + "reportMissingImports": "none", "reportGeneralTypeIssues": "none" }