{{ message }}
Repository navigation
Expand file tree
/
Copy pathtest_easymotion.py
More file actions
2900 lines (2416 loc) · 109 KB
/
Copy pathtest_easymotion.py
File metadata and controls
2900 lines (2416 loc) · 109 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import os
import subprocess
import time
import uuid
from unittest.mock import patch
import pytest
import easymotion
from easymotion import (
Config,
PaneInfo,
_detect_tmux_version,
_expand_tabs,
_clear_options_cache,
assign_hints_by_distance,
calculate_tab_width,
find_matches,
generate_hints,
generate_smartsign_patterns,
get_char_width,
get_string_width,
get_tmux_option,
get_startup_info,
get_true_position,
tmux_capture_pane,
tmux_move_cursor,
update_hints_display,
visual_slice,
)
def test_get_char_width():
assert get_char_width("a") == 1 # ASCII character
assert get_char_width("あ") == 2 # Japanese character (wide)
assert get_char_width("漢") == 2 # Chinese character (wide)
assert get_char_width("한") == 2 # Korean character (wide)
assert get_char_width(" ") == 1 # Space
assert get_char_width("\n") == 1 # Newline
def test_get_string_width():
assert get_string_width("hello") == 5
assert get_string_width("こんにちは") == 10
assert get_string_width("hello こんにちは") == 16
assert get_string_width("") == 0
def test_get_true_position():
assert get_true_position("hello", 3) == 3
assert get_true_position("あいうえお", 4) == 2
assert get_true_position("hello あいうえお", 7) == 7
assert get_true_position("", 5) == 0
# ---------------------------------------------------------------------------
# Tab handling — both tmux version code paths exercised on every CI run via
# the ``tmux_mode`` fixture, regardless of which tmux is actually installed.
# ---------------------------------------------------------------------------
@pytest.fixture(params=[(3, 5), (3, 6)], ids=["tmux<3.6", "tmux>=3.6"])
def tmux_mode(request, monkeypatch):
"""Force easymotion to behave as the parametrized tmux version.
Patches ``TMUX_VERSION`` and clears the width caches so pre-fixture
lookups don't bleed into the test.
"""
version = request.param
monkeypatch.setattr(easymotion, "TMUX_VERSION", version)
easymotion._char_width_no_tab.cache_clear()
easymotion.get_string_width.cache_clear()
yield version
easymotion._char_width_no_tab.cache_clear()
easymotion.get_string_width.cache_clear()
def test_get_char_width_tab(tmux_mode):
# Tab at column 0 is always 8 columns regardless of tmux version
assert get_char_width("\t", 0) == 8
if tmux_mode >= (3, 6):
# tmux 3.6+: position-aware (next 8-column tab stop)
assert get_char_width("\t", 1) == 7
assert get_char_width("\t", 7) == 1
else:
# Older tmux: fixed-width 8-column glyph
assert get_char_width("\t", 1) == 8
assert get_char_width("\t", 7) == 8
def test_get_string_width_tab(tmux_mode):
assert get_string_width("\t") == 8 # leading tab always lands on first stop
if tmux_mode >= (3, 6):
assert get_string_width("a\t") == 8 # 1 + (8-1)
assert get_string_width("1234567\t") == 8 # 7 + (8-7)
assert get_string_width("a\tb\t") == 16 # 1 + 7 + 1 + 7
else:
assert get_string_width("a\t") == 9 # 1 + 8
assert get_string_width("1234567\t") == 15 # 7 + 8
assert get_string_width("a\tb\t") == 18 # 1 + 8 + 1 + 8
def test_get_true_position_tab(tmux_mode):
# Halfway-through-tab still maps to the tab itself, both versions
assert get_true_position("\t", 4) == 1
assert get_true_position("\t", 8) == 1
# 'b' lives at string index 2 in "a\tb" regardless of tmux version;
# the visual column it sits on differs.
if tmux_mode >= (3, 6):
# 'a' = col 0, '\t' = cols 1-7, 'b' = col 8
assert get_true_position("a\tb", 5) == 2 # past tab → 'b'
assert get_true_position("a\tb", 8) == 2 # exactly at 'b'
else:
# 'a' = col 0, '\t' = cols 1-8, 'b' = col 9
assert get_true_position("a\tb", 5) == 2 # past tab → 'b'
assert get_true_position("a\tb", 9) == 2 # exactly at 'b'
assert get_true_position("a\tb", 100) == 3 # past end of string
def test_find_matches_visual_col_after_tab(tmux_mode):
"""Match column reported by find_matches must respect tab width."""
pane = PaneInfo(
pane_id="%0", active=True, start_y=0, height=10, start_x=0, width=80
)
pane.lines = ["a\tb"]
matches = find_matches([pane], "b")
assert len(matches) == 1
_, line_num, visual_col = matches[0]
assert line_num == 0
expected = 8 if tmux_mode >= (3, 6) else 9
assert visual_col == expected
def test_find_matches_visual_col_cjk_tab_mixed(tmux_mode):
"""Regression: find_matches computes visual_col incrementally (lazily
advanced between matches) instead of get_string_width(line[:pos]).
A line mixing wide chars and a tab must yield the same columns —
including the position-dependent tab width on tmux >= 3.6."""
pane = PaneInfo(
pane_id="%0", active=True, start_y=0, height=10, start_x=0, width=80
)
line = "x中\tx漢x"
pane.lines = [line]
matches = find_matches([pane], "x")
cols = [col for (_, _, col) in matches]
if tmux_mode >= (3, 6):
# x=0 | 中=1-2 | tab 3-7 (5 cells to next stop) | x=8 | 漢=9-10 | x=11
assert cols == [0, 8, 11]
else:
# pre-3.6: tab is always 8 cells → x at 11, x at 14
assert cols == [0, 11, 14]
# must agree with the prefix-width helper the old implementation used
assert cols == [get_string_width(line[:i]) for i, c in enumerate(line) if c == "x"]
def test_expand_tabs_uses_pane_local_stops(tmux_mode):
"""Tabs must be expanded against pane-local tab stops so that, after
expansion, the line renders identically in any pane regardless of its
screen position. Otherwise curses (screen-absolute tab stops) would
disagree with tmux (pane-local) in split panes."""
if tmux_mode >= (3, 6):
# Position-aware: tab from col 0 → col 8 (8 cells).
assert _expand_tabs("\t") == " " * 8
# Tab from col 1 → col 8 (7 cells).
assert _expand_tabs("a\t") == "a" + " " * 7
# 7 chars then tab → col 8 (1 cell).
assert _expand_tabs("1234567\t") == "1234567 "
# Two tabs: 0→8 (8 cells), 9→16 (7 cells).
assert _expand_tabs("a\tb\t") == "a" + " " * 7 + "b" + " " * 7
else:
# Pre-3.6 tmux: every tab is 8 cells regardless of position.
assert _expand_tabs("\t") == " " * 8
assert _expand_tabs("a\t") == "a" + " " * 8
assert _expand_tabs("1234567\t") == "1234567" + " " * 8
# No tabs → identity.
assert _expand_tabs("hello") == "hello"
assert _expand_tabs("") == ""
def test_visual_slice_truncates_by_cells_not_chars():
"""line[:pane.width] string-slicing overflows a pane when the line
contains wide chars. visual_slice truncates by visual cells."""
# 5 wide chars = 10 cells; truncate to 6 cells → keep 3 chars + pad.
assert visual_slice("あいうえお", 6) == "あいう"
# Truncating mid-wide-char drops the char and pads with a space.
assert visual_slice("あいうえお", 5) == "あい "
# Short lines get padded on the right.
assert visual_slice("ab", 5) == "ab "
# Empty input pads to full width.
assert visual_slice("", 4) == " "
# Pure ASCII slice behaves like the old string-index version.
assert visual_slice("hello world", 5) == "hello"
def test_calculate_tab_width():
assert calculate_tab_width(0) == 8
assert calculate_tab_width(1) == 7
assert calculate_tab_width(7) == 1
assert calculate_tab_width(8) == 8
assert calculate_tab_width(9) == 7
# Custom tab size
assert calculate_tab_width(0, tab_size=4) == 4
assert calculate_tab_width(3, tab_size=4) == 1
def test_tmux_version_detection_returns_tuple():
version = _detect_tmux_version()
assert isinstance(version, tuple)
assert len(version) == 2
major, minor = version
assert isinstance(major, int) and isinstance(minor, int)
assert major >= 0 and minor >= 0
def test_tmux_version_string_parsing():
"""Replicates the parser inside _detect_tmux_version() for known formats."""
import re
cases = [
("tmux 3.5", (3, 5)),
("tmux 3.6a", (3, 6)),
("tmux 3.0a", (3, 0)),
("tmux 2.9a", (2, 9)),
("tmux next-3.6", (3, 6)),
("tmux next-3.7", (3, 7)),
("tmux 3.1-rc", (3, 1)),
("tmux 3.1-rc2", (3, 1)),
("tmux 3.1c", (3, 1)),
("tmux master", (0, 0)),
("tmux openbsd-6.6", (0, 0)),
("tmux openbsd-7.0", (0, 0)),
]
for version_str, expected in cases:
if "openbsd-" in version_str or "master" in version_str:
result = (0, 0)
else:
match = re.search(r"(?:next-)?(\d+)\.(\d+)", version_str)
result = (int(match.group(1)), int(match.group(2))) if match else (0, 0)
assert result == expected, f"{version_str!r} -> {result}, expected {expected}"
def test_generate_hints():
test_keys = "ab"
hints = generate_hints(test_keys)
expected = ["aa", "ab", "ba", "bb"]
assert hints == expected
def test_generate_hints_no_duplicates():
keys = "asdf" # 4 characters
# Test all possible hint counts from 1 to max (16)
for count in range(1, 17):
hints = generate_hints(keys, count)
# Check no duplicates
assert len(hints) == len(set(hints)), (
f"Duplicates found in hints for count {count}"
)
# For double character hints, check first character usage
single_chars = [h for h in hints if len(h) == 1]
double_chars = [h for h in hints if len(h) == 2]
if double_chars:
for double_char in double_chars:
assert double_char[0] not in single_chars, (
f"Double char hint {double_char} starts with single char hint"
)
# Check all characters are from the key set
assert all(c in keys for h in hints for c in h), (
f"Invalid characters found in hints for count {count}"
)
def test_generate_hints_distribution():
keys = "asdf" # 4 characters
# Case i=4: 4 hints (all single chars)
hints = generate_hints(keys, 4)
assert len(hints) == 4
assert all(len(hint) == 1 for hint in hints)
assert set(hints) == set("asdf")
# Case i=3: 7 hints (3 single + 4 double)
hints = generate_hints(keys, 7)
assert len(hints) == 7
single_chars = [h for h in hints if len(h) == 1]
double_chars = [h for h in hints if len(h) == 2]
assert len(single_chars) == 3
assert len(double_chars) == 4
# Ensure double char prefixes don't overlap with single chars
single_char_set = set(single_chars)
double_char_firsts = set(h[0] for h in double_chars)
assert not (single_char_set & double_char_firsts), (
"Double char prefixes overlap with single chars"
)
# Case i=2: 10 hints (2 single + 8 double)
hints = generate_hints(keys, 10)
assert len(hints) == 10
single_chars = [h for h in hints if len(h) == 1]
double_chars = [h for h in hints if len(h) == 2]
assert len(single_chars) == 2
assert len(double_chars) == 8
# Ensure double char prefixes don't overlap with single chars
single_char_set = set(single_chars)
double_char_firsts = set(h[0] for h in double_chars)
assert not (single_char_set & double_char_firsts), (
"Double char prefixes overlap with single chars"
)
# Case i=1: 13 hints (1 single + 12 double)
hints = generate_hints(keys, 13)
assert len(hints) == 13
single_chars = [h for h in hints if len(h) == 1]
double_chars = [h for h in hints if len(h) == 2]
assert len(single_chars) == 1
assert len(double_chars) == 12
# Ensure double char prefixes don't overlap with single chars
single_char_set = set(single_chars)
double_char_firsts = set(h[0] for h in double_chars)
assert not (single_char_set & double_char_firsts), (
"Double char prefixes overlap with single chars"
)
# Case i=0: 16 hints (all double chars)
hints = generate_hints(keys, 16)
assert len(hints) == 16
assert all(len(hint) == 2 for hint in hints)
# For all double chars case, just ensure no duplicate combinations
assert len(hints) == len(set(hints))
# ============================================================================
# Fixtures for reusable test data
# ============================================================================
@pytest.fixture
def simple_pane():
"""Single pane with basic ASCII content"""
pane = PaneInfo(
pane_id="%1", active=True, start_y=0, height=10, start_x=0, width=80
)
pane.lines = ["hello world", "foo bar baz", "test line"]
return pane
@pytest.fixture
def wide_char_pane():
"""Pane with CJK (wide) characters"""
pane = PaneInfo(
pane_id="%2", active=True, start_y=0, height=10, start_x=0, width=80
)
pane.lines = ["こんにちは world", "你好 hello", "test 테스트"]
return pane
@pytest.fixture
def multi_pane():
"""Multiple panes for cross-pane testing"""
pane1 = PaneInfo(
pane_id="%1", active=True, start_y=0, height=10, start_x=0, width=40
)
pane1.lines = ["left pane", "aaa bbb"]
pane2 = PaneInfo(
pane_id="%2", active=False, start_y=0, height=10, start_x=40, width=40
)
pane2.lines = ["right pane", "ccc ddd"]
return [pane1, pane2]
# ============================================================================
# Tests for find_matches()
# ============================================================================
@pytest.mark.parametrize(
"search_char,expected_min_count",
[
("o", 4), # 'o' in "hello", "world", "foo"
("l", 3), # 'l' in "hello", "world"
("b", 2), # 'b' in "bar", "baz"
("x", 0), # no matches
],
)
def test_find_matches_basic(simple_pane, search_char, expected_min_count):
"""Test basic character matching with various characters"""
matches = find_matches([simple_pane], search_char)
assert len(matches) >= expected_min_count
def test_find_matches_case_insensitive(simple_pane):
"""Test case-insensitive matching (default behavior)"""
# Add a line with uppercase
simple_pane.lines = ["Hello World"]
# With case_sensitive=False, should match both 'h' and 'H'
matches_lower = find_matches([simple_pane], "h", case_sensitive=False)
matches_upper = find_matches([simple_pane], "H", case_sensitive=False)
# Both should find the 'H' in "Hello"
assert len(matches_lower) >= 1
assert len(matches_upper) >= 1
def test_find_matches_smartsign():
"""Test SMARTSIGN feature - various key mappings"""
pane = PaneInfo(
pane_id="%1", active=True, start_y=0, height=10, start_x=0, width=80
)
# Test ',' -> '<' mapping
pane.lines = ["hello, world < test"]
matches = find_matches([pane], ",", smartsign=True)
assert len(matches) == 2 # Should find both ',' and '<'
# Without smartsign, should only find ','
matches = find_matches([pane], ",", smartsign=False)
assert len(matches) == 1
# Test '3' -> '#' mapping
pane.lines = ["test 3# code"]
matches = find_matches([pane], "3", smartsign=True)
assert len(matches) == 2 # Should find both '3' and '#'
# Test '1' -> '!' mapping
pane.lines = ["test 1! code"]
matches = find_matches([pane], "1", smartsign=True)
assert len(matches) == 2 # Should find both '1' and '!'
def test_smartsign_with_case_insensitive():
"""Test smartsign combined with case insensitive mode (1-char and 2-char)"""
pane = PaneInfo(
pane_id="%1", active=True, start_y=0, height=10, start_x=0, width=80
)
# 1-char: smartsign should work with case insensitive mode
pane.lines = ["test 3# CODE"]
matches = find_matches([pane], "3", case_sensitive=False, smartsign=True)
assert len(matches) == 2 # Should find both '3' and '#'
# 2-char: should match all case variations + smartsign variants
pane.lines = ["3X #X 3x #x test"]
matches = find_matches([pane], "3x", case_sensitive=False, smartsign=True)
assert len(matches) == 4 # Matches: 3X, #X, 3x, #x
def test_smartsign_reverse_search():
"""Test that searching for symbol itself (not number) works correctly"""
pane = PaneInfo(
pane_id="%1", active=True, start_y=0, height=10, start_x=0, width=80
)
# Searching '#' should only find '#', not '3'
# because '#' is not a key in SMARTSIGN_TABLE
pane.lines = ["test 3# code"]
matches = find_matches([pane], "#", smartsign=True)
assert len(matches) == 1 # Should only find '#'
# Searching '!' should only find '!'
pane.lines = ["test 1! code"]
matches = find_matches([pane], "!", smartsign=True)
assert len(matches) == 1 # Should only find '!'
# ============================================================================
# Tests for Generic Smartsign Pattern Generation
# ============================================================================
def test_generate_smartsign_patterns_disabled():
"""Test that pattern generation returns original when smartsign is disabled"""
# Should return only the original pattern
assert generate_smartsign_patterns("3", smartsign=False) == ["3"]
assert generate_smartsign_patterns("3,", smartsign=False) == ["3,"]
assert generate_smartsign_patterns("abc", smartsign=False) == ["abc"]
def test_generate_smartsign_patterns_1char():
"""Test 1-character smartsign pattern generation"""
# Character with mapping
patterns = generate_smartsign_patterns("3", smartsign=True)
assert set(patterns) == {"3", "#"}
# Character without mapping
patterns = generate_smartsign_patterns("x", smartsign=True)
assert patterns == ["x"]
def test_generate_smartsign_patterns_2char():
"""Test 2-character smartsign pattern generation (all combinations)"""
# Both characters have mappings: '3' -> '#', ',' -> '<'
patterns = generate_smartsign_patterns("3,", smartsign=True)
assert set(patterns) == {"3,", "#,", "3<", "#<"}
# Only first character has mapping
patterns = generate_smartsign_patterns("3x", smartsign=True)
assert set(patterns) == {"3x", "#x"}
# Only second character has mapping
patterns = generate_smartsign_patterns("x,", smartsign=True)
assert set(patterns) == {"x,", "x<"}
# Neither character has mapping
patterns = generate_smartsign_patterns("ab", smartsign=True)
assert patterns == ["ab"]
def test_generate_smartsign_patterns_3char():
"""Test 3-character pattern generation (verifies extensibility)"""
# All three have mappings: '1' -> '!', '2' -> '@', '3' -> '#'
patterns = generate_smartsign_patterns("123", smartsign=True)
# Should generate 2^3 = 8 combinations
expected = {"123", "!23", "1@3", "12#", "!@3", "!2#", "1@#", "!@#"}
assert set(patterns) == expected
# Mixed: first and last have mappings
patterns = generate_smartsign_patterns("1x3", smartsign=True)
assert set(patterns) == {"1x3", "!x3", "1x#", "!x#"}
def test_find_matches_wide_characters(wide_char_pane):
"""Test matching with wide characters and correct visual position"""
matches = find_matches([wide_char_pane], "w")
# Should find 'w' in "world" on first line
assert len(matches) >= 1
# Check that visual column accounts for wide characters
# 'こんにちは' = 5 chars * 2 width = 10, plus 1 space = 11
pane, line_num, visual_col = matches[0]
assert line_num == 0
assert visual_col == 11 # After wide chars and space
def test_find_matches_multiple_panes(multi_pane):
"""Test finding matches across multiple panes"""
matches = find_matches(multi_pane, "a")
# Should find 'a' in both panes: "pane" (twice), "aaa" (3 times) = 5+ total
assert len(matches) >= 5
# Verify matches come from both panes
pane_ids = {match[0].pane_id for match in matches}
assert "%1" in pane_ids
assert "%2" in pane_ids
def test_find_matches_edge_cases():
"""Test edge cases: empty pane, no matches"""
# Empty pane
empty_pane = PaneInfo(
pane_id="%1", active=True, start_y=0, height=10, start_x=0, width=80
)
empty_pane.lines = []
matches = find_matches([empty_pane], "a")
assert len(matches) == 0
# Pane with content but no matches
pane = PaneInfo(
pane_id="%2", active=True, start_y=0, height=10, start_x=0, width=80
)
pane.lines = ["hello world"]
matches = find_matches([pane], "z")
assert len(matches) == 0
# ============================================================================
# Tests for assign_hints_by_distance()
# ============================================================================
def test_assign_hints_by_distance_basic(simple_pane):
"""Test that hints are assigned based on distance from cursor"""
simple_pane.lines = ["hello world"]
matches = [
(simple_pane, 0, 0), # 'h' at position (0, 0)
(simple_pane, 0, 6), # 'w' at position (0, 6)
]
# Cursor at (0, 0) - closer to first match
hint_mapping = assign_hints_by_distance(matches, cursor_y=0, cursor_x=0)
# Should have 2 hints
assert len(hint_mapping) == 2
# All matches should be in the mapping
mapped_matches = list(hint_mapping.values())
assert all(match in mapped_matches for match in matches)
def test_assign_hints_by_distance_priority():
"""Test that closer matches get simpler (shorter) hints"""
pane = PaneInfo(
pane_id="%1", active=True, start_y=0, height=10, start_x=0, width=80
)
pane.lines = ["a" * 80]
matches = [
(pane, 0, 50), # Far from cursor
(pane, 0, 2), # Close to cursor
(pane, 0, 25), # Medium distance
]
# Cursor at (0, 0)
hint_mapping = assign_hints_by_distance(
matches, cursor_y=0, cursor_x=0, hints_keys="abc"
)
# Find hint for closest match
closest_match = (pane, 0, 2)
closest_hint = [k for k, v in hint_mapping.items() if v == closest_match][0]
# Closest match should get shortest hint
all_hint_lengths = [len(h) for h in hint_mapping.keys()]
assert len(closest_hint) == min(all_hint_lengths)
def test_assign_hints_by_distance_multi_pane(multi_pane):
"""Test hint assignment across multiple panes"""
matches = [
(multi_pane[0], 0, 0), # Left pane at screen x=0
(multi_pane[1], 0, 0), # Right pane at screen x=40
]
# Cursor in left pane at (0, 0)
hint_mapping = assign_hints_by_distance(matches, cursor_y=0, cursor_x=0)
assert len(hint_mapping) == 2
# Verify both matches are assigned hints
mapped_matches = list(hint_mapping.values())
assert matches[0] in mapped_matches
assert matches[1] in mapped_matches
# ============================================================================
# Tests for PaneInfo
# ============================================================================
def test_pane_info_initialization():
"""Test PaneInfo initialization with correct defaults"""
pane = PaneInfo(
pane_id="%1", active=True, start_y=5, height=20, start_x=10, width=80
)
# Check provided values
assert pane.pane_id == "%1"
assert pane.active is True
assert pane.start_y == 5
assert pane.height == 20
assert pane.start_x == 10
assert pane.width == 80
# Check defaults
assert pane.lines == []
assert pane.positions == []
assert pane.copy_mode is False
assert pane.scroll_position == 0
assert pane.cursor_y == 0
assert pane.cursor_x == 0
# ============================================================================
# Integration Test
# ============================================================================
def test_search_to_hint_integration(simple_pane):
"""Integration test: search → find matches → assign hints → verify positions"""
simple_pane.lines = ["hello world test"]
# Step 1: Find matches for 'e'
matches = find_matches([simple_pane], "e")
# Should find 'e' in "hello" and "test"
assert len(matches) >= 2
# Step 2: Assign hints based on distance from cursor
cursor_y = simple_pane.start_y + 0 # First line
cursor_x = simple_pane.start_x + 0 # Start of line
hint_mapping = assign_hints_by_distance(matches, cursor_y, cursor_x)
# Step 3: Verify hints are assigned to all matches
assert len(hint_mapping) == len(matches)
# Step 4: Verify positions can be extracted from matches
for hint, (pane, line_num, visual_col) in hint_mapping.items():
assert pane == simple_pane
assert line_num == 0 # All matches on first line
assert visual_col >= 0
assert visual_col < len(simple_pane.lines[line_num])
# Verify hint is valid
assert len(hint) in [1, 2] # Should be 1 or 2 characters
# ============================================================================
# Tests for 2-Character Search (Issue #6)
# ============================================================================
def test_find_matches_2char_basic(simple_pane):
"""Test 2-character search with basic patterns"""
simple_pane.lines = ["hello world", "foo bar baz", "test line"]
# Search for 'wo'
matches = find_matches([simple_pane], "wo")
assert len(matches) >= 1
# Should find 'wo' in "world"
pane, line_num, visual_col = matches[0]
assert line_num == 0
true_pos = get_true_position(simple_pane.lines[line_num], visual_col)
assert simple_pane.lines[line_num][true_pos : true_pos + 2] == "wo"
def test_find_matches_2char_multiple(simple_pane):
"""Test 2-character search with multiple matches"""
simple_pane.lines = ["hello hello", "test hello"]
# Search for 'he'
matches = find_matches([simple_pane], "he")
# Should find 'he' three times
assert len(matches) == 3
def test_find_matches_2char_case_insensitive(simple_pane):
"""Test 2-character search with case insensitivity"""
simple_pane.lines = ["Hello HELLO heLLo"]
# Search for 'he' should match 'He', 'HE', 'he'
matches = find_matches([simple_pane], "he", case_sensitive=False)
assert len(matches) == 3
# Search for 'HE' should also match all
matches_upper = find_matches([simple_pane], "HE", case_sensitive=False)
assert len(matches_upper) == 3
def test_find_matches_2char_wide_characters(wide_char_pane):
"""Test 2-character search with wide characters"""
# Search for 'ld' in "world"
matches = find_matches([wide_char_pane], "ld")
assert len(matches) >= 1
def test_find_matches_2char_no_match(simple_pane):
"""Test 2-character search with no matches"""
simple_pane.lines = ["hello world"]
# Search for pattern that doesn't exist
matches = find_matches([simple_pane], "xy")
assert len(matches) == 0
def test_find_matches_2char_partial_match(simple_pane):
"""Test that partial matches don't count"""
simple_pane.lines = ["hello"]
# Search for 'lo' - should find only one match at the end
matches = find_matches([simple_pane], "lo")
assert len(matches) == 1
def test_s2_smartsign_single_char_mapping():
"""Test s2 mode with smartsign when only one character has mapping"""
pane = PaneInfo("%1", True, 0, 3, 0, 40)
pane.lines = ["test 3x and #x code"]
# Search for '3x' should match both '3x' and '#x'
matches = find_matches([pane], "3x", smartsign=True)
assert len(matches) == 2
def test_s2_smartsign_both_chars_mapping():
"""Test s2 mode with smartsign when both characters have mappings"""
pane = PaneInfo("%1", True, 0, 3, 0, 60)
# '3' -> '#', ',' -> '<'
pane.lines = ["3, #, 3< #< test"]
# Search for '3,' should match all 4 combinations
matches = find_matches([pane], "3,", smartsign=True)
assert len(matches) == 4
def test_s2_smartsign_no_mapping():
"""Test s2 mode with smartsign when no characters have mappings"""
pane = PaneInfo("%1", True, 0, 3, 0, 40)
pane.lines = ["test ab and cd code"]
# Search for 'ab' should only match 'ab' (no mappings)
matches = find_matches([pane], "ab", smartsign=True)
assert len(matches) == 1
def test_find_matches_2char_at_line_end(simple_pane):
"""Test 2-character search at end of line"""
simple_pane.lines = ["hello"]
# Search for 'lo' at end of line
matches = find_matches([simple_pane], "lo")
assert len(matches) == 1
pane, line_num, visual_col = matches[0]
assert line_num == 0
true_pos = get_true_position(simple_pane.lines[line_num], visual_col)
assert simple_pane.lines[line_num][true_pos : true_pos + 2] == "lo"
# ============================================================================
# Tests for Line-End Hint Restoration Bug Fix
# ============================================================================
def test_positions_construction_at_line_end(simple_pane):
"""Test that positions are correctly constructed when match is at line end"""
simple_pane.lines = ["hello"]
# Find match for 'o' at end of line (position 4)
matches = find_matches([simple_pane], "o")
assert len(matches) == 1
pane, line_num, visual_col = matches[0]
line = pane.lines[line_num]
true_col = get_true_position(line, visual_col)
# At line end, true_col should be the last character
assert true_col == 4 # 'o' is at index 4
assert line[true_col] == "o"
# next_char should be empty because we're at line end
next_char = line[true_col + 1] if true_col + 1 < len(line) else ""
assert next_char == ""
# But next_x should still be within pane bounds (for padding area)
next_x = simple_pane.start_x + visual_col + get_char_width("o")
pane_right_edge = simple_pane.start_x + simple_pane.width
assert next_x < pane_right_edge # Should be within pane for padding
class MockScreen:
"""Mock Screen class to record addstr calls for testing"""
# Attribute constants matching real Screen class
A_NORMAL = 0
A_DIM = 1
A_HINT1 = 2
A_HINT2 = 3
def __init__(self):
self.calls = []
self.refresh_called = False
def addstr(self, y, x, text, attr=0):
"""Record all addstr calls"""
self.calls.append({"y": y, "x": x, "text": text, "attr": attr})
def refresh(self):
"""Record refresh call"""
self.refresh_called = True
def get_calls_at_position(self, x):
"""Helper to get all calls at a specific x position"""
return [call for call in self.calls if call["x"] == x]
def test_hint_restoration_at_line_end():
"""Test that hint at line end is properly restored when first char is pressed"""
# Create a pane with line ending at 'o'
pane = PaneInfo("%1", True, 0, 1, 0, 20)
pane.lines = ["hello"]
# Simulate a two-character hint 'ab' at the last character 'o' (position 4)
# screen_y, screen_x, pane_right_edge, char, next_char, hint
positions = [
(0, 4, 20, "o", "", "ab") # next_char is empty (line end)
]
# Create mock screen
screen = MockScreen()
# Simulate user pressing first hint character 'a'
update_hints_display(screen, positions, "a")
# Verify that refresh was called
assert screen.refresh_called
# Get calls at position 5 (next_x = 4 + get_char_width('o') = 5)
calls_at_next_pos = screen.get_calls_at_position(5)
# Should have one call to restore the second position
assert len(calls_at_next_pos) == 1
# The restored character should be a space, not empty string
assert calls_at_next_pos[0]["text"] == " "
assert calls_at_next_pos[0]["text"] != "" # Bug fix: was empty before
def test_hint_restoration_not_at_line_end():
"""Test that hint restoration works correctly when NOT at line end"""
# Create a pane
pane = PaneInfo("%1", True, 0, 1, 0, 20)
pane.lines = ["hello world"]
# Simulate a two-character hint 'ab' at 'e' (position 1), next_char is 'l'
positions = [
(0, 1, 20, "e", "l", "ab") # next_char is 'l' (not empty)
]
# Create mock screen
screen = MockScreen()
# Simulate user pressing first hint character 'a'
update_hints_display(screen, positions, "a")
# Get calls at position 2 (next_x = 1 + get_char_width('e') = 2)
calls_at_next_pos = screen.get_calls_at_position(2)
# Should restore the actual next character 'l'
assert len(calls_at_next_pos) == 1
assert calls_at_next_pos[0]["text"] == "l"
# =============================================================================
# Integration Tests - Issue #18 Wrapped Line Cursor Jump
# =============================================================================
def tmux_available():
"""Check if tmux is available for integration tests."""
try:
result = subprocess.run(["tmux", "-V"], capture_output=True)
return result.returncode == 0
except FileNotFoundError:
return False
requires_tmux = pytest.mark.skipif(not tmux_available(), reason="tmux not available")
def _server_tmux_version() -> tuple:
try:
out = subprocess.run(["tmux", "-V"], capture_output=True, text=True).stdout
except FileNotFoundError:
return (0, 0)
return easymotion._parse_tmux_version(out.strip())
# The direct frozen-view read needs #{copy_cursor_line} without the
# wide-char truncation bug (fixed in tmux 3.6); older tmux uses the
# legacy live-grid approximations with their documented limitations.
requires_view_read = pytest.mark.skipif(
_server_tmux_version() < (3, 6),
reason="frozen-view direct read needs tmux >= 3.6",
)
class TmuxTestServer:
"""Manage a separate tmux server for integration testing.
Uses -L flag to create an isolated tmux server with controlled pane size.
This is necessary because detached sessions in the main server inherit
the terminal size from attached clients.
"""
def __init__(self, width=30, height=10, shell_command=None):
self.server_name = f"pytest_{uuid.uuid4().hex[:8]}"
self.width = width
self.height = height
self.shell_command = shell_command # e.g. custom-PS1 bash for geometry tests
self.pane_id: str = ""
def start(self):
"""Start the tmux server with controlled dimensions."""
cmd = [
"tmux",
"-L",
self.server_name,
"new-session",
"-d",
"-s",
"test",
"-x",
You can’t perform that action at this time.
