ruvim 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +4 -0
- data/AGENTS.md +84 -0
- data/CLAUDE.md +1 -0
- data/docs/binding.md +23 -0
- data/docs/command.md +85 -0
- data/docs/config.md +2 -2
- data/docs/done.md +21 -0
- data/docs/spec.md +157 -12
- data/docs/todo.md +1 -5
- data/docs/vim_diff.md +94 -172
- data/lib/ruvim/app.rb +882 -69
- data/lib/ruvim/buffer.rb +35 -1
- data/lib/ruvim/cli.rb +12 -3
- data/lib/ruvim/clipboard.rb +2 -0
- data/lib/ruvim/command_invocation.rb +3 -1
- data/lib/ruvim/command_line.rb +2 -0
- data/lib/ruvim/command_registry.rb +2 -0
- data/lib/ruvim/config_dsl.rb +2 -0
- data/lib/ruvim/config_loader.rb +2 -0
- data/lib/ruvim/context.rb +2 -0
- data/lib/ruvim/dispatcher.rb +143 -13
- data/lib/ruvim/display_width.rb +3 -0
- data/lib/ruvim/editor.rb +455 -71
- data/lib/ruvim/ex_command_registry.rb +2 -0
- data/lib/ruvim/global_commands.rb +890 -63
- data/lib/ruvim/highlighter.rb +16 -21
- data/lib/ruvim/input.rb +39 -28
- data/lib/ruvim/keymap_manager.rb +83 -0
- data/lib/ruvim/keyword_chars.rb +2 -0
- data/lib/ruvim/lang/base.rb +25 -0
- data/lib/ruvim/lang/csv.rb +18 -0
- data/lib/ruvim/lang/json.rb +18 -0
- data/lib/ruvim/lang/markdown.rb +170 -0
- data/lib/ruvim/lang/ruby.rb +236 -0
- data/lib/ruvim/lang/scheme.rb +44 -0
- data/lib/ruvim/lang/tsv.rb +19 -0
- data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
- data/lib/ruvim/rich_view/table_renderer.rb +176 -0
- data/lib/ruvim/rich_view.rb +93 -0
- data/lib/ruvim/screen.rb +503 -106
- data/lib/ruvim/terminal.rb +18 -1
- data/lib/ruvim/text_metrics.rb +2 -0
- data/lib/ruvim/version.rb +1 -1
- data/lib/ruvim/window.rb +2 -0
- data/lib/ruvim.rb +14 -0
- data/test/app_completion_test.rb +73 -0
- data/test/app_dot_repeat_test.rb +13 -0
- data/test/app_motion_test.rb +13 -0
- data/test/app_scenario_test.rb +729 -1
- data/test/app_startup_test.rb +187 -0
- data/test/arglist_test.rb +113 -0
- data/test/buffer_test.rb +49 -30
- data/test/dispatcher_test.rb +322 -0
- data/test/editor_register_test.rb +23 -0
- data/test/highlighter_test.rb +121 -0
- data/test/indent_test.rb +201 -0
- data/test/input_screen_integration_test.rb +40 -2
- data/test/markdown_renderer_test.rb +279 -0
- data/test/on_save_hook_test.rb +150 -0
- data/test/rich_view_test.rb +478 -0
- data/test/screen_test.rb +304 -0
- metadata +33 -2
data/test/app_scenario_test.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require_relative "test_helper"
|
|
2
2
|
require "fileutils"
|
|
3
3
|
require "tmpdir"
|
|
4
|
+
require "stringio"
|
|
4
5
|
|
|
5
6
|
class AppScenarioTest < Minitest::Test
|
|
6
7
|
def setup
|
|
@@ -78,6 +79,79 @@ class AppScenarioTest < Minitest::Test
|
|
|
78
79
|
assert_equal ["bc", "bc", "bc"], @editor.current_buffer.lines
|
|
79
80
|
end
|
|
80
81
|
|
|
82
|
+
def test_s_substitutes_char_and_enters_insert_mode
|
|
83
|
+
@editor.current_buffer.replace_all_lines!(["abcd"])
|
|
84
|
+
@editor.current_window.cursor_x = 1
|
|
85
|
+
|
|
86
|
+
feed("s", "X", :escape)
|
|
87
|
+
|
|
88
|
+
assert_equal ["aXcd"], @editor.current_buffer.lines
|
|
89
|
+
assert_equal :normal, @editor.mode
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def test_z_commands_reposition_current_line_in_window
|
|
93
|
+
@editor.current_buffer.replace_all_lines!((1..20).map { |i| "line#{i}" })
|
|
94
|
+
@editor.current_window_view_height_hint = 5
|
|
95
|
+
@editor.current_window.cursor_y = 10
|
|
96
|
+
|
|
97
|
+
feed("z", "t")
|
|
98
|
+
assert_equal 10, @editor.current_window.row_offset
|
|
99
|
+
|
|
100
|
+
feed("z", "z")
|
|
101
|
+
assert_equal 8, @editor.current_window.row_offset
|
|
102
|
+
|
|
103
|
+
feed("z", "b")
|
|
104
|
+
assert_equal 6, @editor.current_window.row_offset
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def test_j_joins_next_line_trimming_indent
|
|
108
|
+
@editor.current_buffer.replace_all_lines!(["foo", " bar", "baz"])
|
|
109
|
+
@editor.current_window.cursor_y = 0
|
|
110
|
+
@editor.current_window.cursor_x = 0
|
|
111
|
+
|
|
112
|
+
feed("J")
|
|
113
|
+
|
|
114
|
+
assert_equal ["foo bar", "baz"], @editor.current_buffer.lines
|
|
115
|
+
assert_equal 3, @editor.current_window.cursor_x
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def test_uppercase_aliases_d_c_s_x_y_and_tilde
|
|
119
|
+
@editor.current_buffer.replace_all_lines!(["abcd"])
|
|
120
|
+
@editor.current_window.cursor_x = 2
|
|
121
|
+
feed("X")
|
|
122
|
+
assert_equal ["acd"], @editor.current_buffer.lines
|
|
123
|
+
|
|
124
|
+
@editor.current_buffer.replace_all_lines!(["abcd"])
|
|
125
|
+
@editor.current_window.cursor_x = 1
|
|
126
|
+
feed("D")
|
|
127
|
+
assert_equal ["a"], @editor.current_buffer.lines
|
|
128
|
+
|
|
129
|
+
@editor.current_buffer.replace_all_lines!(["abcd"])
|
|
130
|
+
@editor.current_window.cursor_x = 1
|
|
131
|
+
feed("C", "X", :escape)
|
|
132
|
+
assert_equal ["aX"], @editor.current_buffer.lines
|
|
133
|
+
assert_equal :normal, @editor.mode
|
|
134
|
+
|
|
135
|
+
@editor.current_buffer.replace_all_lines!(["Abcd"])
|
|
136
|
+
@editor.current_window.cursor_x = 0
|
|
137
|
+
feed("~")
|
|
138
|
+
assert_equal ["abcd"], @editor.current_buffer.lines
|
|
139
|
+
assert_equal 1, @editor.current_window.cursor_x
|
|
140
|
+
|
|
141
|
+
@editor.current_buffer.replace_all_lines!(["hello"])
|
|
142
|
+
@editor.current_window.cursor_x = 0
|
|
143
|
+
feed("Y")
|
|
144
|
+
reg = @editor.get_register("\"")
|
|
145
|
+
assert_equal :linewise, reg[:type]
|
|
146
|
+
assert_equal "hello\n", reg[:text]
|
|
147
|
+
|
|
148
|
+
@editor.current_buffer.replace_all_lines!(["hello"])
|
|
149
|
+
@editor.current_window.cursor_x = 0
|
|
150
|
+
feed("S", "x", :escape)
|
|
151
|
+
assert_equal ["x"], @editor.current_buffer.lines
|
|
152
|
+
assert_equal :normal, @editor.mode
|
|
153
|
+
end
|
|
154
|
+
|
|
81
155
|
def test_expandtab_and_autoindent_in_insert_mode
|
|
82
156
|
@editor.set_option("expandtab", true, scope: :buffer)
|
|
83
157
|
@editor.set_option("tabstop", 4, scope: :buffer)
|
|
@@ -103,6 +177,62 @@ class AppScenarioTest < Minitest::Test
|
|
|
103
177
|
assert_equal ["if x {", " "], @editor.current_buffer.lines
|
|
104
178
|
end
|
|
105
179
|
|
|
180
|
+
def test_smartindent_adds_shiftwidth_after_ruby_def
|
|
181
|
+
@editor.set_option("autoindent", true, scope: :buffer)
|
|
182
|
+
@editor.set_option("smartindent", true, scope: :buffer)
|
|
183
|
+
@editor.set_option("shiftwidth", 2, scope: :buffer)
|
|
184
|
+
@editor.assign_filetype(@editor.current_buffer, "ruby")
|
|
185
|
+
@editor.current_buffer.replace_all_lines!(["def foo"])
|
|
186
|
+
@editor.current_window.cursor_y = 0
|
|
187
|
+
@editor.current_window.cursor_x = @editor.current_buffer.line_length(0)
|
|
188
|
+
|
|
189
|
+
feed("A", :enter, :escape)
|
|
190
|
+
|
|
191
|
+
assert_equal ["def foo", " "], @editor.current_buffer.lines
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def test_smartindent_adds_shiftwidth_after_ruby_do_block
|
|
195
|
+
@editor.set_option("autoindent", true, scope: :buffer)
|
|
196
|
+
@editor.set_option("smartindent", true, scope: :buffer)
|
|
197
|
+
@editor.set_option("shiftwidth", 2, scope: :buffer)
|
|
198
|
+
@editor.assign_filetype(@editor.current_buffer, "ruby")
|
|
199
|
+
@editor.current_buffer.replace_all_lines!([" items.each do |x|"])
|
|
200
|
+
@editor.current_window.cursor_y = 0
|
|
201
|
+
@editor.current_window.cursor_x = @editor.current_buffer.line_length(0)
|
|
202
|
+
|
|
203
|
+
feed("A", :enter, :escape)
|
|
204
|
+
|
|
205
|
+
assert_equal [" items.each do |x|", " "], @editor.current_buffer.lines
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def test_smartindent_dedents_end_in_insert_mode
|
|
209
|
+
@editor.set_option("autoindent", true, scope: :buffer)
|
|
210
|
+
@editor.set_option("smartindent", true, scope: :buffer)
|
|
211
|
+
@editor.set_option("shiftwidth", 2, scope: :buffer)
|
|
212
|
+
@editor.assign_filetype(@editor.current_buffer, "ruby")
|
|
213
|
+
@editor.current_buffer.replace_all_lines!(["def foo"])
|
|
214
|
+
@editor.current_window.cursor_y = 0
|
|
215
|
+
@editor.current_window.cursor_x = @editor.current_buffer.line_length(0)
|
|
216
|
+
|
|
217
|
+
feed("A", :enter, "b", "a", "r", :enter, "e", "n", "d", :escape)
|
|
218
|
+
|
|
219
|
+
assert_equal ["def foo", " bar", "end"], @editor.current_buffer.lines
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def test_smartindent_dedents_else_in_insert_mode
|
|
223
|
+
@editor.set_option("autoindent", true, scope: :buffer)
|
|
224
|
+
@editor.set_option("smartindent", true, scope: :buffer)
|
|
225
|
+
@editor.set_option("shiftwidth", 2, scope: :buffer)
|
|
226
|
+
@editor.assign_filetype(@editor.current_buffer, "ruby")
|
|
227
|
+
@editor.current_buffer.replace_all_lines!(["if cond"])
|
|
228
|
+
@editor.current_window.cursor_y = 0
|
|
229
|
+
@editor.current_window.cursor_x = @editor.current_buffer.line_length(0)
|
|
230
|
+
|
|
231
|
+
feed("A", :enter, "a", :enter, "e", "l", "s", "e", :escape)
|
|
232
|
+
|
|
233
|
+
assert_equal ["if cond", " a", "else"], @editor.current_buffer.lines
|
|
234
|
+
end
|
|
235
|
+
|
|
106
236
|
def test_incsearch_moves_cursor_while_typing_and_escape_restores
|
|
107
237
|
@editor.set_option("incsearch", true, scope: :global)
|
|
108
238
|
@editor.current_buffer.replace_all_lines!(["alpha", "beta", "gamma"])
|
|
@@ -218,6 +348,87 @@ class AppScenarioTest < Minitest::Test
|
|
|
218
348
|
assert_equal [1, 0], [@editor.current_window.cursor_y, @editor.current_window.cursor_x]
|
|
219
349
|
end
|
|
220
350
|
|
|
351
|
+
def test_nomodifiable_buffer_edit_key_does_not_crash
|
|
352
|
+
@editor.current_buffer.replace_all_lines!(["hello"])
|
|
353
|
+
@editor.current_buffer.modifiable = false
|
|
354
|
+
@editor.current_buffer.readonly = true
|
|
355
|
+
|
|
356
|
+
@app.send(:handle_key, "x")
|
|
357
|
+
|
|
358
|
+
assert_equal ["hello"], @editor.current_buffer.lines
|
|
359
|
+
assert_match(/not modifiable/i, @editor.message)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def test_nomodifiable_buffer_insert_mode_is_rejected
|
|
363
|
+
@editor.current_buffer.replace_all_lines!(["hello"])
|
|
364
|
+
@editor.current_buffer.modifiable = false
|
|
365
|
+
@editor.current_buffer.readonly = true
|
|
366
|
+
|
|
367
|
+
@app.send(:handle_key, "i")
|
|
368
|
+
|
|
369
|
+
assert_equal :normal, @editor.mode
|
|
370
|
+
assert_equal ["hello"], @editor.current_buffer.lines
|
|
371
|
+
assert_match(/not modifiable/i, @editor.message)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def test_normal_ctrl_c_stops_stdin_stream_via_default_binding
|
|
375
|
+
stream = StringIO.new("hello\n")
|
|
376
|
+
@app.instance_variable_set(:@stdin_stream_source, stream)
|
|
377
|
+
@app.send(:prepare_stdin_stream_buffer!)
|
|
378
|
+
|
|
379
|
+
@app.send(:handle_key, :ctrl_c)
|
|
380
|
+
|
|
381
|
+
assert_equal :closed, @editor.current_buffer.stream_state
|
|
382
|
+
assert_equal :normal, @editor.mode
|
|
383
|
+
assert_equal true, stream.closed?
|
|
384
|
+
assert_match(/\[stdin\] closed/, @editor.message)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def test_ctrl_z_calls_terminal_suspend
|
|
388
|
+
terminal_stub = Object.new
|
|
389
|
+
terminal_stub.instance_variable_set(:@suspend_calls, 0)
|
|
390
|
+
terminal_stub.define_singleton_method(:suspend_for_tstp) do
|
|
391
|
+
@suspend_calls += 1
|
|
392
|
+
end
|
|
393
|
+
terminal_stub.define_singleton_method(:suspend_calls) { @suspend_calls }
|
|
394
|
+
@app.instance_variable_set(:@terminal, terminal_stub)
|
|
395
|
+
|
|
396
|
+
feed("i", "a", :ctrl_z)
|
|
397
|
+
|
|
398
|
+
assert_equal 1, terminal_stub.suspend_calls
|
|
399
|
+
assert_equal :insert, @editor.mode
|
|
400
|
+
assert_equal ["a"], @editor.current_buffer.lines
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def test_ctrl_z_invalidates_screen_cache_for_full_redraw_after_fg
|
|
404
|
+
terminal_stub = Object.new
|
|
405
|
+
terminal_stub.define_singleton_method(:suspend_for_tstp) {}
|
|
406
|
+
@app.instance_variable_set(:@terminal, terminal_stub)
|
|
407
|
+
|
|
408
|
+
screen_stub = Object.new
|
|
409
|
+
screen_stub.instance_variable_set(:@invalidated, false)
|
|
410
|
+
screen_stub.define_singleton_method(:invalidate_cache!) do
|
|
411
|
+
@invalidated = true
|
|
412
|
+
end
|
|
413
|
+
screen_stub.define_singleton_method(:invalidated?) { @invalidated }
|
|
414
|
+
@app.instance_variable_set(:@screen, screen_stub)
|
|
415
|
+
|
|
416
|
+
feed(:ctrl_z)
|
|
417
|
+
|
|
418
|
+
assert_equal true, screen_stub.invalidated?
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def test_g_and_1g_distinguish_implicit_and_explicit_count
|
|
422
|
+
@editor.current_buffer.replace_all_lines!(%w[a b c d])
|
|
423
|
+
@editor.current_window.cursor_y = 1
|
|
424
|
+
|
|
425
|
+
feed("G")
|
|
426
|
+
assert_equal 3, @editor.current_window.cursor_y
|
|
427
|
+
|
|
428
|
+
feed("1", "G")
|
|
429
|
+
assert_equal 0, @editor.current_window.cursor_y
|
|
430
|
+
end
|
|
431
|
+
|
|
221
432
|
def test_backspace_indent_allows_deleting_autoindent_before_insert_start
|
|
222
433
|
@editor.set_option("backspace", "indent,eol", scope: :global)
|
|
223
434
|
@editor.current_buffer.replace_all_lines!([" abc"])
|
|
@@ -281,6 +492,23 @@ class AppScenarioTest < Minitest::Test
|
|
|
281
492
|
end
|
|
282
493
|
end
|
|
283
494
|
|
|
495
|
+
def test_gf_supports_file_with_line_number_suffix
|
|
496
|
+
Dir.mktmpdir("ruvim-gf-line") do |dir|
|
|
497
|
+
target = File.join(dir, "foo.rb")
|
|
498
|
+
File.write(target, "line1\nline2\nline3\n")
|
|
499
|
+
@editor.current_buffer.path = File.join(dir, "main.txt")
|
|
500
|
+
@editor.current_buffer.replace_all_lines!(["foo.rb:3"])
|
|
501
|
+
@editor.current_window.cursor_y = 0
|
|
502
|
+
@editor.current_window.cursor_x = 3
|
|
503
|
+
@editor.set_option("hidden", true, scope: :global)
|
|
504
|
+
|
|
505
|
+
feed("g", "f")
|
|
506
|
+
|
|
507
|
+
assert_equal File.expand_path(target), File.expand_path(@editor.current_buffer.path)
|
|
508
|
+
assert_equal 2, @editor.current_window.cursor_y
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
284
512
|
def test_showmatch_message_respects_matchtime_and_clears
|
|
285
513
|
@editor.set_option("showmatch", true, scope: :global)
|
|
286
514
|
@editor.set_option("matchtime", 1, scope: :global) # 0.1 sec
|
|
@@ -323,7 +551,7 @@ class AppScenarioTest < Minitest::Test
|
|
|
323
551
|
@editor.current_buffer.replace_all_lines!(["a", "b"])
|
|
324
552
|
@editor.current_window.cursor_y = 0
|
|
325
553
|
|
|
326
|
-
feed("
|
|
554
|
+
feed("_")
|
|
327
555
|
assert @editor.message_error?
|
|
328
556
|
assert_match(/Unknown key:/, @editor.message)
|
|
329
557
|
|
|
@@ -344,4 +572,504 @@ class AppScenarioTest < Minitest::Test
|
|
|
344
572
|
assert_equal "", @editor.message
|
|
345
573
|
assert_equal 1, @editor.current_window.cursor_y
|
|
346
574
|
end
|
|
575
|
+
|
|
576
|
+
# --- hit-enter prompt tests ---
|
|
577
|
+
|
|
578
|
+
def test_ls_with_multiple_buffers_enters_hit_enter_mode
|
|
579
|
+
@editor.add_empty_buffer(path: "second.rb")
|
|
580
|
+
@dispatcher.dispatch_ex(@editor, "ls")
|
|
581
|
+
|
|
582
|
+
assert_equal :hit_enter, @editor.mode
|
|
583
|
+
assert_instance_of Array, @editor.hit_enter_lines
|
|
584
|
+
assert_operator @editor.hit_enter_lines.length, :>=, 2
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def test_ls_with_single_buffer_uses_normal_echo
|
|
588
|
+
@dispatcher.dispatch_ex(@editor, "ls")
|
|
589
|
+
|
|
590
|
+
refute_equal :hit_enter, @editor.mode
|
|
591
|
+
refute_nil @editor.message
|
|
592
|
+
refute @editor.message.to_s.empty?
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def test_hit_enter_dismiss_with_enter
|
|
596
|
+
@editor.add_empty_buffer(path: "second.rb")
|
|
597
|
+
@dispatcher.dispatch_ex(@editor, "ls")
|
|
598
|
+
assert_equal :hit_enter, @editor.mode
|
|
599
|
+
|
|
600
|
+
feed(:enter)
|
|
601
|
+
|
|
602
|
+
assert_equal :normal, @editor.mode
|
|
603
|
+
assert_nil @editor.hit_enter_lines
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def test_hit_enter_dismiss_with_escape
|
|
607
|
+
@editor.add_empty_buffer(path: "second.rb")
|
|
608
|
+
@dispatcher.dispatch_ex(@editor, "ls")
|
|
609
|
+
assert_equal :hit_enter, @editor.mode
|
|
610
|
+
|
|
611
|
+
feed(:escape)
|
|
612
|
+
|
|
613
|
+
assert_equal :normal, @editor.mode
|
|
614
|
+
assert_nil @editor.hit_enter_lines
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def test_hit_enter_dismiss_with_ctrl_c
|
|
618
|
+
@editor.add_empty_buffer(path: "second.rb")
|
|
619
|
+
@dispatcher.dispatch_ex(@editor, "ls")
|
|
620
|
+
assert_equal :hit_enter, @editor.mode
|
|
621
|
+
|
|
622
|
+
feed(:ctrl_c)
|
|
623
|
+
|
|
624
|
+
assert_equal :normal, @editor.mode
|
|
625
|
+
assert_nil @editor.hit_enter_lines
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def test_hit_enter_colon_enters_command_line
|
|
629
|
+
@editor.add_empty_buffer(path: "second.rb")
|
|
630
|
+
@dispatcher.dispatch_ex(@editor, "ls")
|
|
631
|
+
assert_equal :hit_enter, @editor.mode
|
|
632
|
+
|
|
633
|
+
feed(":")
|
|
634
|
+
|
|
635
|
+
assert_equal :command_line, @editor.mode
|
|
636
|
+
assert_nil @editor.hit_enter_lines
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def test_hit_enter_slash_enters_search
|
|
640
|
+
@editor.add_empty_buffer(path: "second.rb")
|
|
641
|
+
@dispatcher.dispatch_ex(@editor, "ls")
|
|
642
|
+
assert_equal :hit_enter, @editor.mode
|
|
643
|
+
|
|
644
|
+
feed("/")
|
|
645
|
+
|
|
646
|
+
assert_equal :command_line, @editor.mode
|
|
647
|
+
assert_equal "/", @editor.command_line_prefix
|
|
648
|
+
assert_nil @editor.hit_enter_lines
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def test_hit_enter_question_enters_reverse_search
|
|
652
|
+
@editor.add_empty_buffer(path: "second.rb")
|
|
653
|
+
@dispatcher.dispatch_ex(@editor, "ls")
|
|
654
|
+
assert_equal :hit_enter, @editor.mode
|
|
655
|
+
|
|
656
|
+
feed("?")
|
|
657
|
+
|
|
658
|
+
assert_equal :command_line, @editor.mode
|
|
659
|
+
assert_equal "?", @editor.command_line_prefix
|
|
660
|
+
assert_nil @editor.hit_enter_lines
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def test_args_with_multiple_files_enters_hit_enter_mode
|
|
664
|
+
@editor.set_arglist(["a.rb", "b.rb", "c.rb"])
|
|
665
|
+
@dispatcher.dispatch_ex(@editor, "args")
|
|
666
|
+
|
|
667
|
+
assert_equal :hit_enter, @editor.mode
|
|
668
|
+
assert_instance_of Array, @editor.hit_enter_lines
|
|
669
|
+
assert_equal 3, @editor.hit_enter_lines.length
|
|
670
|
+
assert_match(/\[a\.rb\]/, @editor.hit_enter_lines[0])
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def test_args_with_single_file_uses_normal_echo
|
|
674
|
+
@editor.set_arglist(["a.rb"])
|
|
675
|
+
@dispatcher.dispatch_ex(@editor, "args")
|
|
676
|
+
|
|
677
|
+
refute_equal :hit_enter, @editor.mode
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def test_set_no_args_enters_hit_enter_mode
|
|
681
|
+
@dispatcher.dispatch_ex(@editor, "set")
|
|
682
|
+
|
|
683
|
+
# option_snapshot returns many options, so always > 1 line
|
|
684
|
+
assert_equal :hit_enter, @editor.mode
|
|
685
|
+
assert_instance_of Array, @editor.hit_enter_lines
|
|
686
|
+
assert_operator @editor.hit_enter_lines.length, :>, 1
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def test_batch_insert_handles_pasted_text_correctly
|
|
690
|
+
@editor.current_buffer.replace_all_lines!([""])
|
|
691
|
+
# Simulate pasting "Hello World\n" in insert mode (batch of characters)
|
|
692
|
+
feed("i", *"Hello World".chars, :enter, *"Second line".chars, :escape)
|
|
693
|
+
|
|
694
|
+
assert_equal ["Hello World", "Second line"], @editor.current_buffer.lines
|
|
695
|
+
assert_equal :normal, @editor.mode
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def test_batch_insert_stops_on_escape
|
|
699
|
+
@editor.current_buffer.replace_all_lines!([""])
|
|
700
|
+
# Escape exits insert mode; subsequent keys are normal-mode commands
|
|
701
|
+
feed("i", "a", "b", "c", :escape)
|
|
702
|
+
|
|
703
|
+
assert_equal ["abc"], @editor.current_buffer.lines
|
|
704
|
+
assert_equal :normal, @editor.mode
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def test_ls_format_shows_vim_style_output
|
|
708
|
+
@editor.add_empty_buffer(path: "second.rb")
|
|
709
|
+
@dispatcher.dispatch_ex(@editor, "ls")
|
|
710
|
+
|
|
711
|
+
lines = @editor.hit_enter_lines
|
|
712
|
+
# Each line should contain the buffer id and name
|
|
713
|
+
assert_match(/1.*\[No Name\]/, lines[0])
|
|
714
|
+
assert_match(/2.*"second\.rb"/, lines[1])
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
def test_equal_equal_indents_current_line
|
|
718
|
+
@editor.current_buffer.replace_all_lines!(["def foo", "bar", "end"])
|
|
719
|
+
@editor.assign_filetype(@editor.current_buffer, "ruby")
|
|
720
|
+
@editor.current_window.cursor_y = 1
|
|
721
|
+
@editor.current_window.cursor_x = 0
|
|
722
|
+
|
|
723
|
+
feed("=", "=")
|
|
724
|
+
|
|
725
|
+
assert_equal ["def foo", " bar", "end"], @editor.current_buffer.lines
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def test_equal_j_indents_two_lines
|
|
729
|
+
@editor.current_buffer.replace_all_lines!(["def foo", "bar", "baz", "end"])
|
|
730
|
+
@editor.assign_filetype(@editor.current_buffer, "ruby")
|
|
731
|
+
@editor.current_window.cursor_y = 1
|
|
732
|
+
@editor.current_window.cursor_x = 0
|
|
733
|
+
|
|
734
|
+
feed("=", "j")
|
|
735
|
+
|
|
736
|
+
assert_equal ["def foo", " bar", " baz", "end"], @editor.current_buffer.lines
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def test_visual_equal_indents_selection
|
|
740
|
+
@editor.current_buffer.replace_all_lines!(["def foo", "bar", "end"])
|
|
741
|
+
@editor.assign_filetype(@editor.current_buffer, "ruby")
|
|
742
|
+
@editor.current_window.cursor_y = 0
|
|
743
|
+
@editor.current_window.cursor_x = 0
|
|
744
|
+
|
|
745
|
+
feed("V", "j", "j", "=")
|
|
746
|
+
|
|
747
|
+
assert_equal ["def foo", " bar", "end"], @editor.current_buffer.lines
|
|
748
|
+
assert_equal :normal, @editor.mode
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
# :qa / :qall tests
|
|
752
|
+
|
|
753
|
+
def test_qa_quits_with_multiple_windows
|
|
754
|
+
@dispatcher.dispatch_ex(@editor, "split")
|
|
755
|
+
assert_equal 2, @editor.window_count
|
|
756
|
+
|
|
757
|
+
@dispatcher.dispatch_ex(@editor, "qa")
|
|
758
|
+
assert_equal false, @editor.running?
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def test_qa_refuses_with_unsaved_changes
|
|
762
|
+
@editor.current_buffer.replace_all_lines!(["modified"])
|
|
763
|
+
@editor.current_buffer.instance_variable_set(:@modified, true)
|
|
764
|
+
|
|
765
|
+
@dispatcher.dispatch_ex(@editor, "qa")
|
|
766
|
+
assert @editor.running?
|
|
767
|
+
assert_match(/unsaved changes/, @editor.message)
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def test_qa_bang_forces_quit_with_unsaved_changes
|
|
771
|
+
@editor.current_buffer.replace_all_lines!(["modified"])
|
|
772
|
+
@editor.current_buffer.instance_variable_set(:@modified, true)
|
|
773
|
+
|
|
774
|
+
@dispatcher.dispatch_ex(@editor, "qa!")
|
|
775
|
+
assert_equal false, @editor.running?
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def test_wqa_writes_all_and_quits
|
|
779
|
+
Dir.mktmpdir do |dir|
|
|
780
|
+
path1 = File.join(dir, "a.txt")
|
|
781
|
+
path2 = File.join(dir, "b.txt")
|
|
782
|
+
File.write(path1, "")
|
|
783
|
+
File.write(path2, "")
|
|
784
|
+
|
|
785
|
+
@editor.current_buffer.replace_all_lines!(["hello"])
|
|
786
|
+
@editor.current_buffer.instance_variable_set(:@path, path1)
|
|
787
|
+
@editor.current_buffer.instance_variable_set(:@modified, true)
|
|
788
|
+
|
|
789
|
+
buf2 = @editor.add_empty_buffer(path: path2)
|
|
790
|
+
buf2.replace_all_lines!(["world"])
|
|
791
|
+
buf2.instance_variable_set(:@modified, true)
|
|
792
|
+
|
|
793
|
+
@dispatcher.dispatch_ex(@editor, "wqa")
|
|
794
|
+
assert_equal false, @editor.running?
|
|
795
|
+
assert_equal "hello", File.read(path1)
|
|
796
|
+
assert_equal "world", File.read(path2)
|
|
797
|
+
end
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
def test_shift_right_splits_when_single_window
|
|
801
|
+
assert_equal 1, @editor.window_count
|
|
802
|
+
first_win = @editor.current_window
|
|
803
|
+
feed(:shift_right)
|
|
804
|
+
assert_equal 2, @editor.window_count
|
|
805
|
+
# Focus should be on the new (right) window
|
|
806
|
+
refute_equal first_win.id, @editor.current_window.id
|
|
807
|
+
assert_equal :vertical, @editor.window_layout
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
def test_shift_left_splits_when_single_window
|
|
811
|
+
assert_equal 1, @editor.window_count
|
|
812
|
+
first_win = @editor.current_window
|
|
813
|
+
feed(:shift_left)
|
|
814
|
+
assert_equal 2, @editor.window_count
|
|
815
|
+
# Focus should be on the new (left) window
|
|
816
|
+
refute_equal first_win.id, @editor.current_window.id
|
|
817
|
+
assert_equal :vertical, @editor.window_layout
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
def test_shift_down_splits_when_single_window
|
|
821
|
+
assert_equal 1, @editor.window_count
|
|
822
|
+
first_win = @editor.current_window
|
|
823
|
+
feed(:shift_down)
|
|
824
|
+
assert_equal 2, @editor.window_count
|
|
825
|
+
refute_equal first_win.id, @editor.current_window.id
|
|
826
|
+
assert_equal :horizontal, @editor.window_layout
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
def test_shift_up_splits_when_single_window
|
|
830
|
+
assert_equal 1, @editor.window_count
|
|
831
|
+
first_win = @editor.current_window
|
|
832
|
+
feed(:shift_up)
|
|
833
|
+
assert_equal 2, @editor.window_count
|
|
834
|
+
refute_equal first_win.id, @editor.current_window.id
|
|
835
|
+
assert_equal :horizontal, @editor.window_layout
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def test_shift_right_splits_even_with_horizontal_split
|
|
839
|
+
# Horizontal split exists, but no window to the right → vsplit
|
|
840
|
+
@editor.split_current_window(layout: :horizontal)
|
|
841
|
+
assert_equal 2, @editor.window_count
|
|
842
|
+
feed(:shift_right)
|
|
843
|
+
assert_equal 3, @editor.window_count
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
def test_shift_left_splits_even_with_horizontal_split
|
|
847
|
+
@editor.split_current_window(layout: :horizontal)
|
|
848
|
+
assert_equal 2, @editor.window_count
|
|
849
|
+
feed(:shift_left)
|
|
850
|
+
assert_equal 3, @editor.window_count
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
def test_shift_down_splits_even_with_vertical_split
|
|
854
|
+
@editor.split_current_window(layout: :vertical)
|
|
855
|
+
assert_equal 2, @editor.window_count
|
|
856
|
+
feed(:shift_down)
|
|
857
|
+
assert_equal 3, @editor.window_count
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
def test_shift_up_splits_even_with_vertical_split
|
|
861
|
+
@editor.split_current_window(layout: :vertical)
|
|
862
|
+
assert_equal 2, @editor.window_count
|
|
863
|
+
feed(:shift_up)
|
|
864
|
+
assert_equal 3, @editor.window_count
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
def test_shift_arrow_moves_window_focus_when_multiple_windows
|
|
868
|
+
# Create a vertical split so we have two windows
|
|
869
|
+
first_win = @editor.current_window
|
|
870
|
+
@editor.split_current_window(layout: :vertical)
|
|
871
|
+
second_win = @editor.current_window
|
|
872
|
+
|
|
873
|
+
# After split, focus is on the new (second) window
|
|
874
|
+
assert_equal second_win.id, @editor.current_window.id
|
|
875
|
+
|
|
876
|
+
# Shift+Left should move focus to the left window (no new split)
|
|
877
|
+
feed(:shift_left)
|
|
878
|
+
assert_equal first_win.id, @editor.current_window.id
|
|
879
|
+
assert_equal 2, @editor.window_count
|
|
880
|
+
|
|
881
|
+
# Shift+Right should move focus back to the right window
|
|
882
|
+
feed(:shift_right)
|
|
883
|
+
assert_equal second_win.id, @editor.current_window.id
|
|
884
|
+
assert_equal 2, @editor.window_count
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
def test_shift_arrow_up_down_moves_window_focus_horizontal_split
|
|
888
|
+
# Create a horizontal split so we have two windows
|
|
889
|
+
first_win = @editor.current_window
|
|
890
|
+
@editor.split_current_window(layout: :horizontal)
|
|
891
|
+
second_win = @editor.current_window
|
|
892
|
+
|
|
893
|
+
assert_equal second_win.id, @editor.current_window.id
|
|
894
|
+
|
|
895
|
+
# Shift+Up should move focus to the upper window (no new split)
|
|
896
|
+
feed(:shift_up)
|
|
897
|
+
assert_equal first_win.id, @editor.current_window.id
|
|
898
|
+
assert_equal 2, @editor.window_count
|
|
899
|
+
|
|
900
|
+
# Shift+Down should move focus back to the lower window
|
|
901
|
+
feed(:shift_down)
|
|
902
|
+
assert_equal second_win.id, @editor.current_window.id
|
|
903
|
+
assert_equal 2, @editor.window_count
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
# --- Nested layout tree tests ---
|
|
907
|
+
|
|
908
|
+
def test_vsplit_then_split_creates_nested_layout
|
|
909
|
+
# Start with 1 window (win1)
|
|
910
|
+
win1 = @editor.current_window
|
|
911
|
+
# vsplit → creates win2 to the right
|
|
912
|
+
@editor.split_current_window(layout: :vertical)
|
|
913
|
+
win2 = @editor.current_window
|
|
914
|
+
assert_equal 2, @editor.window_count
|
|
915
|
+
|
|
916
|
+
# split the right window (win2) horizontally → creates win3 below win2
|
|
917
|
+
@editor.split_current_window(layout: :horizontal)
|
|
918
|
+
win3 = @editor.current_window
|
|
919
|
+
assert_equal 3, @editor.window_count
|
|
920
|
+
|
|
921
|
+
# Layout tree should be: vsplit[ win1, hsplit[ win2, win3 ] ]
|
|
922
|
+
tree = @editor.layout_tree
|
|
923
|
+
assert_equal :vsplit, tree[:type]
|
|
924
|
+
assert_equal 2, tree[:children].length
|
|
925
|
+
assert_equal :window, tree[:children][0][:type]
|
|
926
|
+
assert_equal win1.id, tree[:children][0][:id]
|
|
927
|
+
assert_equal :hsplit, tree[:children][1][:type]
|
|
928
|
+
assert_equal 2, tree[:children][1][:children].length
|
|
929
|
+
|
|
930
|
+
# window_order should traverse leaves left-to-right, top-to-bottom
|
|
931
|
+
assert_equal [win1.id, win2.id, win3.id], @editor.window_order
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
def test_split_then_vsplit_creates_nested_layout
|
|
935
|
+
# split → creates win2 below
|
|
936
|
+
@editor.split_current_window(layout: :horizontal)
|
|
937
|
+
assert_equal 2, @editor.window_count
|
|
938
|
+
|
|
939
|
+
# vsplit the lower window → creates win3 to the right of win2
|
|
940
|
+
@editor.split_current_window(layout: :vertical)
|
|
941
|
+
assert_equal 3, @editor.window_count
|
|
942
|
+
|
|
943
|
+
# Layout tree should be: hsplit[ win1, vsplit[ win2, win3 ] ]
|
|
944
|
+
tree = @editor.layout_tree
|
|
945
|
+
assert_equal :hsplit, tree[:type]
|
|
946
|
+
assert_equal 2, tree[:children].length
|
|
947
|
+
assert_equal :window, tree[:children][0][:type]
|
|
948
|
+
assert_equal :vsplit, tree[:children][1][:type]
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
def test_close_window_simplifies_nested_tree
|
|
952
|
+
@editor.split_current_window(layout: :vertical)
|
|
953
|
+
@editor.split_current_window(layout: :horizontal)
|
|
954
|
+
win3 = @editor.current_window
|
|
955
|
+
assert_equal 3, @editor.window_count
|
|
956
|
+
|
|
957
|
+
# Close win3 → hsplit node should collapse, leaving vsplit[ win1, win2 ]
|
|
958
|
+
@editor.close_window(win3.id)
|
|
959
|
+
assert_equal 2, @editor.window_count
|
|
960
|
+
|
|
961
|
+
tree = @editor.layout_tree
|
|
962
|
+
assert_equal :vsplit, tree[:type]
|
|
963
|
+
assert_equal 2, tree[:children].length
|
|
964
|
+
assert_equal :window, tree[:children][0][:type]
|
|
965
|
+
assert_equal :window, tree[:children][1][:type]
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
def test_close_window_to_single_produces_single_layout
|
|
969
|
+
@editor.split_current_window(layout: :vertical)
|
|
970
|
+
win2 = @editor.current_window
|
|
971
|
+
assert_equal 2, @editor.window_count
|
|
972
|
+
|
|
973
|
+
@editor.close_window(win2.id)
|
|
974
|
+
assert_equal 1, @editor.window_count
|
|
975
|
+
assert_equal :single, @editor.window_layout
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
def test_focus_window_direction_in_nested_layout
|
|
979
|
+
# Create vsplit[ win1, hsplit[ win2, win3 ] ]
|
|
980
|
+
win1 = @editor.current_window
|
|
981
|
+
@editor.split_current_window(layout: :vertical)
|
|
982
|
+
win2 = @editor.current_window
|
|
983
|
+
@editor.split_current_window(layout: :horizontal)
|
|
984
|
+
win3 = @editor.current_window
|
|
985
|
+
|
|
986
|
+
# From win3 (bottom-right), going left should reach win1
|
|
987
|
+
@editor.focus_window(win3.id)
|
|
988
|
+
@editor.focus_window_direction(:left)
|
|
989
|
+
assert_equal win1.id, @editor.current_window_id
|
|
990
|
+
|
|
991
|
+
# From win1 (left), going right should reach win2 or win3
|
|
992
|
+
@editor.focus_window_direction(:right)
|
|
993
|
+
assert_includes [win2.id, win3.id], @editor.current_window_id
|
|
994
|
+
|
|
995
|
+
# From win2 (top-right), going down should reach win3
|
|
996
|
+
@editor.focus_window(win2.id)
|
|
997
|
+
@editor.focus_window_direction(:down)
|
|
998
|
+
assert_equal win3.id, @editor.current_window_id
|
|
999
|
+
|
|
1000
|
+
# From win3 (bottom-right), going up should reach win2
|
|
1001
|
+
@editor.focus_window_direction(:up)
|
|
1002
|
+
assert_equal win2.id, @editor.current_window_id
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
def test_shift_left_does_not_split_at_edge_of_existing_vsplit
|
|
1006
|
+
# vsplit creates [win1, win2], focus on win2
|
|
1007
|
+
@editor.split_current_window(layout: :vertical)
|
|
1008
|
+
# Move focus to left (win1)
|
|
1009
|
+
feed(:shift_left)
|
|
1010
|
+
assert_equal 2, @editor.window_count
|
|
1011
|
+
|
|
1012
|
+
# Now we're on win1 (leftmost). Shift+Left should NOT split because
|
|
1013
|
+
# there are already windows on the same axis (horizontal neighbors exist).
|
|
1014
|
+
feed(:shift_left)
|
|
1015
|
+
assert_equal 2, @editor.window_count, "Should not split at edge of existing vsplit"
|
|
1016
|
+
|
|
1017
|
+
# Pressing again should still not split
|
|
1018
|
+
feed(:shift_left)
|
|
1019
|
+
assert_equal 2, @editor.window_count
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
def test_shift_left_splits_bottom_window_in_nested_layout
|
|
1023
|
+
# Create layout: hsplit[ vsplit[win1, win2], win3 ]
|
|
1024
|
+
# Start with win1
|
|
1025
|
+
win1 = @editor.current_window
|
|
1026
|
+
# vsplit → vsplit[win1, win2]
|
|
1027
|
+
@editor.split_current_window(layout: :vertical)
|
|
1028
|
+
win2 = @editor.current_window
|
|
1029
|
+
# Focus back to win1, then split horizontally from win1
|
|
1030
|
+
# Actually, easier: split from win2 horizontally to get the right structure
|
|
1031
|
+
# Let me build it differently: start fresh
|
|
1032
|
+
@editor.focus_window(win1.id)
|
|
1033
|
+
|
|
1034
|
+
# From win1, hsplit → hsplit[win1, win3], but we want vsplit on top.
|
|
1035
|
+
# Let me just build the tree directly.
|
|
1036
|
+
# Better approach: vsplit first, then move up and hsplit from the vsplit pair
|
|
1037
|
+
# Actually: vsplit[win1, win2], then from win1 do hsplit → hsplit[vsplit[...], win3]
|
|
1038
|
+
# No, that's wrong. Let me think:
|
|
1039
|
+
# We want hsplit[ vsplit[A, B], C ]
|
|
1040
|
+
# Step 1: split (horizontal) → hsplit[win1, win3], focus on win3
|
|
1041
|
+
@editor.close_window(win2.id)
|
|
1042
|
+
assert_equal 1, @editor.window_count
|
|
1043
|
+
@editor.split_current_window(layout: :horizontal)
|
|
1044
|
+
win3 = @editor.current_window
|
|
1045
|
+
# Step 2: focus win1 (top), then vsplit → vsplit[win1, win2] inside hsplit
|
|
1046
|
+
@editor.focus_window(win1.id)
|
|
1047
|
+
@editor.split_current_window(layout: :vertical)
|
|
1048
|
+
win2 = @editor.current_window
|
|
1049
|
+
assert_equal 3, @editor.window_count
|
|
1050
|
+
|
|
1051
|
+
# Layout should be: hsplit[ vsplit[win1, win2], win3 ]
|
|
1052
|
+
tree = @editor.layout_tree
|
|
1053
|
+
assert_equal :hsplit, tree[:type]
|
|
1054
|
+
assert_equal :vsplit, tree[:children][0][:type]
|
|
1055
|
+
assert_equal :window, tree[:children][1][:type]
|
|
1056
|
+
assert_equal win3.id, tree[:children][1][:id]
|
|
1057
|
+
|
|
1058
|
+
# From win3 (full-width bottom), Shift+Left should SPLIT (no vsplit ancestor)
|
|
1059
|
+
@editor.focus_window(win3.id)
|
|
1060
|
+
feed(:shift_left)
|
|
1061
|
+
assert_equal 4, @editor.window_count, "Shift+Left from full-width bottom should vsplit it"
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
def test_same_direction_split_merges_into_parent
|
|
1065
|
+
# hsplit[ win1, win2 ], then split win2 again horizontally
|
|
1066
|
+
@editor.split_current_window(layout: :horizontal)
|
|
1067
|
+
@editor.split_current_window(layout: :horizontal)
|
|
1068
|
+
assert_equal 3, @editor.window_count
|
|
1069
|
+
|
|
1070
|
+
# All three should be in a single hsplit (no nested hsplit inside hsplit)
|
|
1071
|
+
tree = @editor.layout_tree
|
|
1072
|
+
assert_equal :hsplit, tree[:type]
|
|
1073
|
+
assert_equal 3, tree[:children].length
|
|
1074
|
+
end
|
|
347
1075
|
end
|