teek-ui 0.1.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.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +452 -0
  4. data/lib/teek/ui/canvas_item.rb +270 -0
  5. data/lib/teek/ui/component_handle.rb +32 -0
  6. data/lib/teek/ui/document.rb +185 -0
  7. data/lib/teek/ui/errors.rb +41 -0
  8. data/lib/teek/ui/event_binding.rb +22 -0
  9. data/lib/teek/ui/event_bus.rb +53 -0
  10. data/lib/teek/ui/grid_validator.rb +89 -0
  11. data/lib/teek/ui/handle.rb +585 -0
  12. data/lib/teek/ui/image.rb +58 -0
  13. data/lib/teek/ui/keysyms.rb +72 -0
  14. data/lib/teek/ui/menu_builder.rb +130 -0
  15. data/lib/teek/ui/menu_entry_addressing.rb +62 -0
  16. data/lib/teek/ui/modal_stack.rb +99 -0
  17. data/lib/teek/ui/mouse_events.rb +31 -0
  18. data/lib/teek/ui/node.rb +141 -0
  19. data/lib/teek/ui/option_dump_parsing.rb +31 -0
  20. data/lib/teek/ui/overlay_anchors.rb +29 -0
  21. data/lib/teek/ui/overlay_validator.rb +33 -0
  22. data/lib/teek/ui/pane_validator.rb +30 -0
  23. data/lib/teek/ui/realized_node.rb +25 -0
  24. data/lib/teek/ui/realizer.rb +577 -0
  25. data/lib/teek/ui/scope.rb +40 -0
  26. data/lib/teek/ui/screens.rb +141 -0
  27. data/lib/teek/ui/session.rb +417 -0
  28. data/lib/teek/ui/tab_validator.rb +30 -0
  29. data/lib/teek/ui/text_content.rb +321 -0
  30. data/lib/teek/ui/tree_inspector.rb +97 -0
  31. data/lib/teek/ui/validator.rb +131 -0
  32. data/lib/teek/ui/var.rb +98 -0
  33. data/lib/teek/ui/version.rb +7 -0
  34. data/lib/teek/ui/widget_addressing.rb +47 -0
  35. data/lib/teek/ui/widget_dsl.rb +512 -0
  36. data/lib/teek/ui/widget_type.rb +242 -0
  37. data/lib/teek/ui/widget_types/button.rb +7 -0
  38. data/lib/teek/ui/widget_types/canvas.rb +15 -0
  39. data/lib/teek/ui/widget_types/checkbox.rb +7 -0
  40. data/lib/teek/ui/widget_types/column.rb +14 -0
  41. data/lib/teek/ui/widget_types/context_menu.rb +15 -0
  42. data/lib/teek/ui/widget_types/divider.rb +10 -0
  43. data/lib/teek/ui/widget_types/dropdown.rb +7 -0
  44. data/lib/teek/ui/widget_types/grid.rb +12 -0
  45. data/lib/teek/ui/widget_types/group.rb +7 -0
  46. data/lib/teek/ui/widget_types/label.rb +7 -0
  47. data/lib/teek/ui/widget_types/list.rb +7 -0
  48. data/lib/teek/ui/widget_types/menu_bar.rb +26 -0
  49. data/lib/teek/ui/widget_types/menu_checkbox.rb +14 -0
  50. data/lib/teek/ui/widget_types/menu_item.rb +20 -0
  51. data/lib/teek/ui/widget_types/menu_radio.rb +14 -0
  52. data/lib/teek/ui/widget_types/number_box.rb +7 -0
  53. data/lib/teek/ui/widget_types/pane.rb +37 -0
  54. data/lib/teek/ui/widget_types/panel.rb +7 -0
  55. data/lib/teek/ui/widget_types/progress.rb +7 -0
  56. data/lib/teek/ui/widget_types/radio.rb +7 -0
  57. data/lib/teek/ui/widget_types/row.rb +14 -0
  58. data/lib/teek/ui/widget_types/scrollable.rb +17 -0
  59. data/lib/teek/ui/widget_types/slider.rb +7 -0
  60. data/lib/teek/ui/widget_types/spacer.rb +14 -0
  61. data/lib/teek/ui/widget_types/split.rb +17 -0
  62. data/lib/teek/ui/widget_types/tab.rb +35 -0
  63. data/lib/teek/ui/widget_types/table.rb +9 -0
  64. data/lib/teek/ui/widget_types/tabs.rb +10 -0
  65. data/lib/teek/ui/widget_types/text_area.rb +7 -0
  66. data/lib/teek/ui/widget_types/text_box.rb +7 -0
  67. data/lib/teek/ui/widget_types/tree.rb +9 -0
  68. data/lib/teek/ui/widget_types/window.rb +50 -0
  69. data/lib/teek/ui/widget_types.rb +121 -0
  70. data/lib/teek/ui/widget_validators.rb +62 -0
  71. data/lib/teek/ui.rb +54 -0
  72. data/teek-ui.gemspec +24 -0
  73. data/test/support/fake_app.rb +57 -0
  74. data/test/test_busy.rb +82 -0
  75. data/test/test_canvas_items.rb +551 -0
  76. data/test/test_clipboard.rb +87 -0
  77. data/test/test_close_handling.rb +118 -0
  78. data/test/test_component.rb +151 -0
  79. data/test/test_component_realtk.rb +120 -0
  80. data/test/test_debug_info.rb +158 -0
  81. data/test/test_document.rb +317 -0
  82. data/test/test_event_bus.rb +163 -0
  83. data/test/test_events.rb +164 -0
  84. data/test/test_fake_app_contract.rb +62 -0
  85. data/test/test_grid.rb +97 -0
  86. data/test/test_handle.rb +386 -0
  87. data/test/test_handle_destroy_realtk.rb +344 -0
  88. data/test/test_helper.rb +20 -0
  89. data/test/test_image.rb +24 -0
  90. data/test/test_image_realtk.rb +146 -0
  91. data/test/test_incremental_realize.rb +203 -0
  92. data/test/test_keysyms.rb +76 -0
  93. data/test/test_layout.rb +185 -0
  94. data/test/test_lazy_realize.rb +128 -0
  95. data/test/test_managed_window.rb +304 -0
  96. data/test/test_menu_dsl.rb +230 -0
  97. data/test/test_menu_entry_addressing.rb +73 -0
  98. data/test/test_menu_realize.rb +296 -0
  99. data/test/test_modal_handle.rb +147 -0
  100. data/test/test_modal_stack.rb +219 -0
  101. data/test/test_modal_stack_realtk.rb +131 -0
  102. data/test/test_native_scrollable.rb +250 -0
  103. data/test/test_node.rb +193 -0
  104. data/test/test_overlay.rb +139 -0
  105. data/test/test_raw.rb +58 -0
  106. data/test/test_reactive_vars.rb +118 -0
  107. data/test/test_realizer.rb +264 -0
  108. data/test/test_scope.rb +38 -0
  109. data/test/test_screens.rb +260 -0
  110. data/test/test_screens_realtk.rb +314 -0
  111. data/test/test_scrollable.rb +185 -0
  112. data/test/test_scrollable_wheel.rb +172 -0
  113. data/test/test_scrollbar_auto_hide.rb +127 -0
  114. data/test/test_split.rb +145 -0
  115. data/test/test_tabs.rb +201 -0
  116. data/test/test_text_content.rb +664 -0
  117. data/test/test_toast.rb +126 -0
  118. data/test/test_tree_inspector.rb +183 -0
  119. data/test/test_ui.rb +428 -0
  120. data/test/test_validator.rb +209 -0
  121. data/test/test_var.rb +32 -0
  122. data/test/test_widget_dsl.rb +621 -0
  123. data/test/test_widget_introspection.rb +126 -0
  124. data/test/test_widget_types.rb +429 -0
  125. data/test/test_widget_types_realtk.rb +82 -0
  126. data/test/test_widget_validators.rb +82 -0
  127. metadata +207 -0
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'test_helper'
4
+ require 'teek/ui/document'
5
+ require 'teek/ui/scope'
6
+ require 'teek/ui/realized_node'
7
+
8
+ class TestDocument < Minitest::Test
9
+ def test_root_is_an_empty_root_node
10
+ document = Teek::UI::Document.new
11
+
12
+ assert_kind_of Teek::UI::Node, document.root
13
+ assert_equal :root, document.root.type
14
+ assert_equal [], document.root.children
15
+ end
16
+
17
+ def test_create_builds_a_node_but_does_not_attach_it_to_any_parent
18
+ document = Teek::UI::Document.new
19
+
20
+ node = document.create(type: :button, opts: { text: 'Go' })
21
+
22
+ assert_equal :button, node.type
23
+ assert_equal({ text: 'Go' }, node.opts)
24
+ assert_equal [], document.root.children, "create should not attach the node anywhere - the caller decides the parent"
25
+ end
26
+
27
+ def test_named_node_is_findable_by_symbol_after_attaching
28
+ document = Teek::UI::Document.new
29
+ node = document.create(type: :button, name: :save)
30
+ document.root.add_child(node)
31
+
32
+ assert_same node, document.find(:save)
33
+ assert_same node, document[:save]
34
+ end
35
+
36
+ def test_create_gives_the_node_a_back_reference_to_the_document
37
+ document = Teek::UI::Document.new
38
+
39
+ node = document.create(type: :button, name: :save)
40
+
41
+ assert_same document, node.document
42
+ end
43
+
44
+ def test_unregister_removes_a_named_node_from_the_index
45
+ document = Teek::UI::Document.new
46
+ node = document.create(type: :button, name: :save)
47
+ document.root.add_child(node)
48
+
49
+ document.unregister(node)
50
+
51
+ assert_nil document.find(:save)
52
+ end
53
+
54
+ def test_unregister_frees_the_name_for_reuse
55
+ document = Teek::UI::Document.new
56
+ first = document.create(type: :button, name: :save)
57
+ document.root.add_child(first)
58
+ document.unregister(first)
59
+
60
+ second = document.create(type: :button, name: :save)
61
+
62
+ assert_same second, document.find(:save)
63
+ end
64
+
65
+ def test_unregister_on_an_unnamed_node_is_a_safe_no_op
66
+ document = Teek::UI::Document.new
67
+ node = document.create(type: :button)
68
+
69
+ document.unregister(node)
70
+ end
71
+
72
+ def test_unregister_only_affects_the_given_nodes_own_scope
73
+ document = Teek::UI::Document.new
74
+ scope = Teek::UI::Scope.new(:a)
75
+ top_level = document.create(type: :button, name: :save)
76
+ document.root.add_child(top_level)
77
+ scoped = document.create(type: :button, name: :save, scope: scope)
78
+
79
+ document.unregister(scoped)
80
+
81
+ assert_same top_level, document.find(:save)
82
+ assert_nil document.find(:save, scope: scope)
83
+ end
84
+
85
+ def test_find_returns_nil_for_an_unknown_name
86
+ document = Teek::UI::Document.new
87
+
88
+ assert_nil document.find(:nope)
89
+ assert_nil document[:nope]
90
+ end
91
+
92
+ def test_unnamed_nodes_get_a_distinct_auto_generated_key
93
+ document = Teek::UI::Document.new
94
+
95
+ a = document.create(type: :button)
96
+ b = document.create(type: :button)
97
+
98
+ refute_nil a.key
99
+ refute_nil b.key
100
+ refute_equal a.key, b.key
101
+ end
102
+
103
+ def test_named_node_key_is_the_name
104
+ document = Teek::UI::Document.new
105
+
106
+ node = document.create(type: :button, name: :save)
107
+
108
+ assert_equal 'save', node.key
109
+ end
110
+
111
+ def test_duplicate_explicit_name_is_detected
112
+ document = Teek::UI::Document.new
113
+ document.create(type: :button, name: :save)
114
+
115
+ error = assert_raises(ArgumentError) { document.create(type: :button, name: :save) }
116
+ assert_match(/save/, error.message)
117
+ end
118
+
119
+ def test_same_name_in_two_different_scopes_does_not_collide
120
+ document = Teek::UI::Document.new
121
+ scope_a = Teek::UI::Scope.new(:a)
122
+ scope_b = Teek::UI::Scope.new(:b)
123
+
124
+ a = document.create(type: :button, name: :save, scope: scope_a)
125
+ b = document.create(type: :button, name: :save, scope: scope_b)
126
+
127
+ refute_same a, b
128
+ assert_same a, document.find(:save, scope: scope_a)
129
+ assert_same b, document.find(:save, scope: scope_b)
130
+ end
131
+
132
+ def test_same_name_in_the_same_scope_still_collides
133
+ document = Teek::UI::Document.new
134
+ scope = Teek::UI::Scope.new(:a)
135
+ document.create(type: :button, name: :save, scope: scope)
136
+
137
+ error = assert_raises(ArgumentError) {
138
+ document.create(type: :button, name: :save, scope: scope)
139
+ }
140
+ assert_match(/save/, error.message)
141
+ end
142
+
143
+ def test_scoped_name_does_not_collide_with_the_same_name_at_top_level
144
+ document = Teek::UI::Document.new
145
+ scope = Teek::UI::Scope.new(:a)
146
+
147
+ top_level = document.create(type: :button, name: :save)
148
+ scoped = document.create(type: :button, name: :save, scope: scope)
149
+
150
+ refute_same top_level, scoped
151
+ assert_same top_level, document.find(:save)
152
+ assert_same scoped, document.find(:save, scope: scope)
153
+ end
154
+
155
+ def test_find_with_a_scope_does_not_find_a_top_level_node_of_the_same_name
156
+ document = Teek::UI::Document.new
157
+ document.create(type: :button, name: :save)
158
+
159
+ assert_nil document.find(:save, scope: Teek::UI::Scope.new(:a))
160
+ end
161
+
162
+ def test_find_without_a_scope_does_not_find_a_scoped_node_of_the_same_name
163
+ document = Teek::UI::Document.new
164
+ document.create(type: :button, name: :save, scope: Teek::UI::Scope.new(:a))
165
+
166
+ assert_nil document.find(:save)
167
+ end
168
+
169
+ def test_two_scopes_with_the_same_label_do_not_share_a_namespace
170
+ document = Teek::UI::Document.new
171
+ first = Teek::UI::Scope.new(:widget)
172
+ second = Teek::UI::Scope.new(:widget)
173
+
174
+ a = document.create(type: :button, name: :save, scope: first)
175
+ b = document.create(type: :button, name: :save, scope: second)
176
+
177
+ refute_same a, b
178
+ assert_same a, document.find(:save, scope: first)
179
+ assert_same b, document.find(:save, scope: second)
180
+ end
181
+
182
+ def test_each_node_traverses_the_whole_tree_from_root
183
+ document = Teek::UI::Document.new
184
+ a = document.create(type: :button, name: :a)
185
+ b = document.create(type: :column, name: :b)
186
+ c = document.create(type: :button, name: :c)
187
+ document.root.add_child(a)
188
+ document.root.add_child(b)
189
+ b.add_child(c)
190
+
191
+ visited = []
192
+ document.each_node { |n| visited << n }
193
+
194
+ assert_equal [document.root, a, b, c], visited
195
+ end
196
+
197
+ def test_claim_path_segment_returns_the_bare_segment_the_first_time
198
+ document = Teek::UI::Document.new
199
+
200
+ assert_equal 'save', document.claim_path_segment('.list', 'save')
201
+ end
202
+
203
+ def test_claim_path_segment_disambiguates_a_repeat_under_the_same_parent
204
+ document = Teek::UI::Document.new
205
+
206
+ document.claim_path_segment('.list', 'save')
207
+
208
+ assert_equal 'save#2', document.claim_path_segment('.list', 'save')
209
+ assert_equal 'save#3', document.claim_path_segment('.list', 'save')
210
+ end
211
+
212
+ def test_claim_path_segment_tracks_independently_per_parent_path
213
+ document = Teek::UI::Document.new
214
+
215
+ document.claim_path_segment('.sidebar', 'save')
216
+
217
+ assert_equal 'save', document.claim_path_segment('.main', 'save'),
218
+ "the same segment under a DIFFERENT parent should never need disambiguating"
219
+ end
220
+
221
+ def test_find_by_path_returns_the_node_whose_realized_path_matches
222
+ document = Teek::UI::Document.new
223
+ node = document.create(type: :button, name: :save)
224
+ document.root.add_child(node)
225
+ node.realized = Teek::UI::RealizedNode.new(app: nil, path: '.win.save')
226
+
227
+ assert_same node, document.find_by_path('.win.save')
228
+ end
229
+
230
+ def test_find_by_path_returns_nil_for_an_unrealized_node
231
+ document = Teek::UI::Document.new
232
+ node = document.create(type: :button, name: :save)
233
+ document.root.add_child(node)
234
+
235
+ assert_nil document.find_by_path('.win.save')
236
+ end
237
+
238
+ def test_find_by_path_returns_nil_for_an_unknown_path
239
+ document = Teek::UI::Document.new
240
+ node = document.create(type: :button, name: :save)
241
+ document.root.add_child(node)
242
+ node.realized = Teek::UI::RealizedNode.new(app: nil, path: '.win.save')
243
+
244
+ assert_nil document.find_by_path('.nope')
245
+ end
246
+
247
+ def test_find_by_path_distinguishes_a_scrollbar_wrapped_nodes_path_from_its_arrange_path
248
+ document = Teek::UI::Document.new
249
+ node = document.create(type: :list, name: :items)
250
+ document.root.add_child(node)
251
+ node.realized = Teek::UI::RealizedNode.new(app: nil, path: '.wrap.items', arrange_path: '.wrap')
252
+
253
+ assert_same node, document.find_by_path('.wrap.items'), "path is the real widget - what a Handle acts on"
254
+ assert_nil document.find_by_path('.wrap'), "arrange_path is only where the parent's layout places it, not this node's own address"
255
+ end
256
+
257
+ def test_claim_path_segment_persists_across_separate_calls_not_just_one_realizer_instance
258
+ document = Teek::UI::Document.new
259
+
260
+ document.claim_path_segment('.list', 'save')
261
+ # simulates two SEPARATE Realizer instances (e.g. the initial realize,
262
+ # then a later lazily-realized screen sharing the same internal
263
+ # component name) both claiming under the same real parent - the
264
+ # whole reason this lives on Document rather than Realizer itself.
265
+ assert_equal 'save#2', document.claim_path_segment('.list', 'save')
266
+ end
267
+
268
+ def test_add_child_notifies_append_subscribers
269
+ document = Teek::UI::Document.new
270
+ parent = document.create(type: :column, name: :ctrl)
271
+ child = document.create(type: :button)
272
+ seen = []
273
+ document.subscribe(:append) { |p, c| seen << [p, c] }
274
+
275
+ parent.add_child(child)
276
+
277
+ assert_equal [[parent, child]], seen
278
+ end
279
+
280
+ def test_add_child_is_silent_with_no_subscribers
281
+ document = Teek::UI::Document.new
282
+ parent = document.create(type: :column)
283
+ child = document.create(type: :button)
284
+
285
+ parent.add_child(child)
286
+ end
287
+
288
+ def test_notify_reaches_every_subscriber_of_that_event_in_order
289
+ document = Teek::UI::Document.new
290
+ seen = []
291
+ document.subscribe(:push) { |*args| seen << [:first, args] }
292
+ document.subscribe(:push) { |*args| seen << [:second, args] }
293
+
294
+ document.notify(:push, :some_node, 'column')
295
+
296
+ assert_equal [[:first, [:some_node, 'column']], [:second, [:some_node, 'column']]], seen
297
+ end
298
+
299
+ def test_notify_only_reaches_subscribers_of_that_specific_event
300
+ document = Teek::UI::Document.new
301
+ push_seen = []
302
+ document.subscribe(:push) { |*args| push_seen << args }
303
+
304
+ document.notify(:pop, :some_node, 'column')
305
+
306
+ assert_equal [], push_seen
307
+ end
308
+
309
+ def test_a_raw_node_new_with_no_document_never_calls_notify
310
+ parent = Teek::UI::Node.new(type: :column)
311
+ child = Teek::UI::Node.new(type: :button)
312
+
313
+ parent.add_child(child)
314
+
315
+ assert_equal [child], parent.children
316
+ end
317
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'test_helper'
4
+ require 'teek/ui/event_bus'
5
+ require 'teek/ui/session'
6
+
7
+ # In-process publish/subscribe for decoupled app events - not Tk events,
8
+ # complementary to on_click/on_key (see Handle). Headless: no Tk/interpreter
9
+ # involved at all, pure Ruby object messaging.
10
+ class TestEventBus < Minitest::Test
11
+ def test_on_and_emit
12
+ bus = Teek::UI::EventBus.new
13
+ received = nil
14
+
15
+ bus.on(:ping) { |val| received = val }
16
+ bus.emit(:ping, 42)
17
+
18
+ assert_equal 42, received
19
+ end
20
+
21
+ def test_emit_with_no_subscribers_is_a_no_op
22
+ bus = Teek::UI::EventBus.new
23
+
24
+ bus.emit(:ghost, 1, 2, 3) # should not raise
25
+ end
26
+
27
+ def test_multiple_subscribers_all_fire_in_subscription_order
28
+ bus = Teek::UI::EventBus.new
29
+ results = []
30
+
31
+ bus.on(:tick) { |v| results << "a:#{v}" }
32
+ bus.on(:tick) { |v| results << "b:#{v}" }
33
+ bus.emit(:tick, 7)
34
+
35
+ assert_equal ['a:7', 'b:7'], results
36
+ end
37
+
38
+ def test_different_events_are_independent
39
+ bus = Teek::UI::EventBus.new
40
+ a = nil
41
+ b = nil
42
+
43
+ bus.on(:foo) { |v| a = v }
44
+ bus.on(:bar) { |v| b = v }
45
+ bus.emit(:foo, 1)
46
+
47
+ assert_equal 1, a
48
+ assert_nil b
49
+ end
50
+
51
+ def test_emit_forwards_multiple_positional_args
52
+ bus = Teek::UI::EventBus.new
53
+ received = nil
54
+
55
+ bus.on(:multi) { |x, y| received = [x, y] }
56
+ bus.emit(:multi, :a, :b)
57
+
58
+ assert_equal [:a, :b], received
59
+ end
60
+
61
+ def test_emit_forwards_keyword_args
62
+ bus = Teek::UI::EventBus.new
63
+ received = nil
64
+
65
+ bus.on(:kw) { |name:, val:| received = { name: name, val: val } }
66
+ bus.emit(:kw, name: 'scale', val: 3)
67
+
68
+ assert_equal({ name: 'scale', val: 3 }, received)
69
+ end
70
+
71
+ def test_off_removes_a_specific_subscriber
72
+ bus = Teek::UI::EventBus.new
73
+ received = []
74
+
75
+ block = bus.on(:evt) { |v| received << v }
76
+ bus.emit(:evt, 1)
77
+ bus.off(:evt, block)
78
+ bus.emit(:evt, 2)
79
+
80
+ assert_equal [1], received
81
+ end
82
+
83
+ def test_off_only_removes_the_given_subscriber_not_others_on_the_same_event
84
+ bus = Teek::UI::EventBus.new
85
+ received = []
86
+
87
+ keep = bus.on(:evt) { |v| received << "keep:#{v}" }
88
+ drop = bus.on(:evt) { |v| received << "drop:#{v}" }
89
+ bus.off(:evt, drop)
90
+ bus.emit(:evt, 1)
91
+
92
+ assert_equal ['keep:1'], received
93
+ refute_nil keep
94
+ end
95
+
96
+ def test_on_returns_the_block_for_a_later_off
97
+ bus = Teek::UI::EventBus.new
98
+
99
+ block = bus.on(:x) { }
100
+
101
+ assert_instance_of Proc, block
102
+ end
103
+
104
+ def test_two_independent_buses_do_not_see_each_others_events
105
+ bus_a = Teek::UI::EventBus.new
106
+ bus_b = Teek::UI::EventBus.new
107
+ received_a = nil
108
+ received_b = nil
109
+
110
+ bus_a.on(:shared_name) { |v| received_a = v }
111
+ bus_b.on(:shared_name) { |v| received_b = v }
112
+ bus_a.emit(:shared_name, 'from a')
113
+
114
+ assert_equal 'from a', received_a
115
+ assert_nil received_b, "bus_b should never see bus_a's emit"
116
+ end
117
+ end
118
+
119
+ # Session#on/#emit/#off delegate to one EventBus per session - app-scoped,
120
+ # not a global singleton, so two Teek::UI.app instances in the same
121
+ # process never see each other's events. Headless: works before realize,
122
+ # no Tk/interpreter needed at all.
123
+ class TestSessionEventBus < Minitest::Test
124
+ def build_session
125
+ Teek::UI::Session.new(title: 'Event Bus Test')
126
+ end
127
+
128
+ def test_on_and_emit_work_before_realize
129
+ session = build_session
130
+ received = nil
131
+
132
+ session.on(:item_selected) { |id| received = id }
133
+ session.emit(:item_selected, 42)
134
+
135
+ assert_equal 42, received
136
+ end
137
+
138
+ def test_off_removes_a_specific_subscriber
139
+ session = build_session
140
+ received = []
141
+
142
+ block = session.on(:evt) { |v| received << v }
143
+ session.emit(:evt, 1)
144
+ session.off(:evt, block)
145
+ session.emit(:evt, 2)
146
+
147
+ assert_equal [1], received
148
+ end
149
+
150
+ def test_two_independent_sessions_do_not_see_each_others_events
151
+ session_a = build_session
152
+ session_b = build_session
153
+ received_a = nil
154
+ received_b = nil
155
+
156
+ session_a.on(:theme_changed) { |v| received_a = v }
157
+ session_b.on(:theme_changed) { |v| received_b = v }
158
+ session_a.emit(:theme_changed, 'dark')
159
+
160
+ assert_equal 'dark', received_a
161
+ assert_nil received_b, "session_b should never see session_a's emit"
162
+ end
163
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require_relative '../../test/tk_test_helper'
5
+
6
+ class TestEvents < Minitest::Test
7
+ include TeekTestHelper
8
+
9
+ def test_on_click_fires_on_a_real_button_event
10
+ assert_tk_app("on_click should fire when Button-1 is generated on the widget") do
11
+ require 'teek/ui'
12
+
13
+ clicked = false
14
+ session = Teek::UI.app(title: 'Events Test') { |ui| ui.button(:go, text: 'Go') }
15
+ session.run_async
16
+ session.app.update
17
+ session[:go].on_click { clicked = true }
18
+
19
+ session.app.tcl_eval("event generate #{session[:go].path} <Button-1>")
20
+ session.app.update
21
+
22
+ assert clicked, "on_click did not fire"
23
+
24
+ session.app.destroy
25
+ end
26
+ end
27
+
28
+ def test_on_right_click_fires_on_the_linux_windows_right_click_event
29
+ assert_tk_app("on_right_click should fire on Button-3 (Linux/Windows right-click)") do
30
+ require 'teek/ui'
31
+
32
+ clicked = false
33
+ session = Teek::UI.app(title: 'Events Test') { |ui| ui.button(:go, text: 'Go') }
34
+ session.run_async
35
+ session.app.update
36
+ session[:go].on_right_click { clicked = true }
37
+
38
+ session.app.tcl_eval("event generate #{session[:go].path} <Button-3>")
39
+ session.app.update
40
+
41
+ assert clicked, "on_right_click did not fire on Button-3"
42
+
43
+ session.app.destroy
44
+ end
45
+ end
46
+
47
+ def test_on_drag_delivers_typed_integer_coordinates
48
+ assert_tk_app("on_drag should deliver Integer x/y, not raw Tcl strings") do
49
+ require 'teek/ui'
50
+
51
+ received = nil
52
+ session = Teek::UI.app(title: 'Events Test') { |ui| ui.panel(:area, width: 200, height: 200) }
53
+ session.run_async
54
+ session.app.update
55
+ session[:area].on_drag { |x, y| received = [x, y] }
56
+
57
+ session.app.tcl_eval("event generate #{session[:area].path} <B1-Motion> -x 40 -y 55")
58
+ session.app.update
59
+
60
+ assert_equal [40, 55], received
61
+ assert_kind_of Integer, received[0]
62
+ assert_kind_of Integer, received[1]
63
+
64
+ session.app.destroy
65
+ end
66
+ end
67
+
68
+ def test_on_drag_converts_coordinates_when_bound_to_a_canvas
69
+ assert_tk_app("on_drag should convert raw window coords through canvasx/canvasy when bound to a canvas") do
70
+ require 'teek/ui'
71
+
72
+ received = nil
73
+ session = Teek::UI.app(title: 'Events Test') { |ui| ui.canvas(:board, width: 200, height: 200) }
74
+ session.run_async
75
+ session.app.update
76
+
77
+ board_path = session[:board].path
78
+ # scroll the canvas so canvasx/canvasy meaningfully differ from raw %x/%y -
79
+ # otherwise a bug that skips conversion entirely could still pass by luck.
80
+ session.app.tcl_eval("#{board_path} configure -scrollregion {0 0 1000 1000}")
81
+ session.app.tcl_eval("#{board_path} xview moveto 0.5")
82
+ session.app.update
83
+
84
+ session[:board].on_drag { |x, y| received = [x, y] }
85
+ session.app.tcl_eval("event generate #{board_path} <B1-Motion> -x 50 -y 60")
86
+ session.app.update
87
+
88
+ expected_x = session.app.command(board_path, :canvasx, 50).to_f.round
89
+ expected_y = session.app.command(board_path, :canvasy, 60).to_f.round
90
+
91
+ refute_nil received
92
+ assert_equal [expected_x, expected_y], received
93
+ refute_equal [50, 60], received, "the canvas is scrolled, so conversion should actually change the coordinates"
94
+
95
+ session.app.destroy
96
+ end
97
+ end
98
+
99
+ def test_on_key_resolves_a_friendly_symbol
100
+ assert_tk_app("on_key(:enter) should fire on a real Return keypress") do
101
+ require 'teek/ui'
102
+
103
+ fired = false
104
+ session = Teek::UI.app(title: 'Events Test') { |ui| ui.text_box(:query) }
105
+ session.run_async
106
+ session.app.update
107
+ session[:query].on_key(:enter) { fired = true }
108
+
109
+ path = session[:query].path
110
+ session.app.tcl_eval("focus -force #{path}")
111
+ session.app.update
112
+ session.app.tcl_eval("event generate #{path} <Return>")
113
+ session.app.update
114
+
115
+ assert fired, "on_key(:enter) did not fire"
116
+
117
+ session.app.destroy
118
+ end
119
+ end
120
+
121
+ def test_on_key_resolves_a_ctrl_modifier_string
122
+ assert_tk_app("on_key('Ctrl-s') should fire on a real Control-s keypress") do
123
+ require 'teek/ui'
124
+
125
+ fired = false
126
+ session = Teek::UI.app(title: 'Events Test') { |ui| ui.text_box(:query) }
127
+ session.run_async
128
+ session.app.update
129
+ session[:query].on_key('Ctrl-s') { fired = true }
130
+
131
+ path = session[:query].path
132
+ session.app.tcl_eval("focus -force #{path}")
133
+ session.app.update
134
+ session.app.tcl_eval("event generate #{path} <Control-s>")
135
+ session.app.update
136
+
137
+ assert fired, "on_key('Ctrl-s') did not fire"
138
+
139
+ session.app.destroy
140
+ end
141
+ end
142
+
143
+ def test_on_key_shift_tab_fires_via_the_iso_left_tab_gotcha
144
+ assert_tk_app("on_key('Shift-Tab') should fire even though X11 delivers it as ISO_Left_Tab, not Shift-Tab") do
145
+ require 'teek/ui'
146
+
147
+ fired = false
148
+ session = Teek::UI.app(title: 'Events Test') { |ui| ui.text_box(:query) }
149
+ session.run_async
150
+ session.app.update
151
+ session[:query].on_key('Shift-Tab') { fired = true }
152
+
153
+ path = session[:query].path
154
+ session.app.tcl_eval("focus -force #{path}")
155
+ session.app.update
156
+ session.app.tcl_eval("event generate #{path} <ISO_Left_Tab>")
157
+ session.app.update
158
+
159
+ assert fired, "on_key('Shift-Tab') did not fire for the X11 ISO_Left_Tab keysym"
160
+
161
+ session.app.destroy
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'test_helper'
4
+ require 'teek'
5
+
6
+ # support/fake_app.rb's FakeApp/FakeWindow stand in for Teek::App/
7
+ # Teek::Window in headless tests - Realizer/Handle only ever call a
8
+ # handful of methods on whichever app object they're given, so the fakes
9
+ # implement just those, not the real classes' full surface. What they DO
10
+ # implement needs to stay call-compatible with the real thing, or a
11
+ # headless test could keep passing against a contract production code no
12
+ # longer honors. RSpec has verifying doubles (instance_double) for
13
+ # exactly this; Minitest doesn't, so this hand-rolls the same idea as an
14
+ # ordinary failing test instead of a load-time check that would just
15
+ # blow up the whole suite with a raw exception.
16
+ class TestFakeAppContract < Minitest::Test
17
+ FAKE_APP_METHODS = %i[command bind on_close popup_menu window].freeze
18
+ FAKE_WINDOW_METHODS = %i[modal grab_release].freeze
19
+
20
+ def test_fake_app_methods_are_signature_compatible_with_teek_app
21
+ FAKE_APP_METHODS.each do |name|
22
+ assert_signature_compatible(FakeApp.instance_method(name), Teek::App.instance_method(name), name)
23
+ end
24
+ end
25
+
26
+ def test_fake_window_methods_are_signature_compatible_with_teek_window
27
+ FAKE_WINDOW_METHODS.each do |name|
28
+ assert_signature_compatible(FakeWindow.instance_method(name), Teek::Window.instance_method(name), name)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ # Keyword names must match exactly (a renamed/added/removed kwarg is
35
+ # the drift most likely to silently break a fake) - required vs
36
+ # optional isn't distinguished, since the fake defaulting a kwarg the
37
+ # real method requires is a harmless, common simplification for a stub.
38
+ # Positional/rest SHAPE (ignoring req vs opt, same reasoning) must also
39
+ # line up, since a dropped or added *args changes what call sites the
40
+ # fake can stand in for. +&block+ presence is deliberately NOT compared
41
+ # - a real method can accept a block via bare +yield+/+block_given?+
42
+ # with no +&block+ param at all (see Teek::Window#modal), which
43
+ # +Method#parameters+ has no way to see - only a fake DECLARING a block
44
+ # param when the real method can't take one at all would be a genuine
45
+ # mismatch, and that's not a failure mode worth the false positives
46
+ # this check would otherwise raise on every yield-based real method.
47
+ def assert_signature_compatible(fake_method, real_method, name)
48
+ assert_equal keyword_names(real_method), keyword_names(fake_method),
49
+ "FakeApp/FakeWindow##{name}'s keyword arguments have drifted from the real Teek method's"
50
+
51
+ assert_equal positional_shape(real_method), positional_shape(fake_method),
52
+ "FakeApp/FakeWindow##{name}'s positional/rest shape has drifted from the real Teek method's"
53
+ end
54
+
55
+ def keyword_names(method)
56
+ method.parameters.select { |kind, _| %i[key keyreq].include?(kind) }.map { |_, param_name| param_name }.sort
57
+ end
58
+
59
+ def positional_shape(method)
60
+ method.parameters.reject { |kind, _| %i[key keyreq keyrest block].include?(kind) }.map { |kind, _| kind }
61
+ end
62
+ end