ruby_wasm_ui 0.8.1

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 (99) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/rules/ruby_comments.mdc +29 -0
  3. data/.github/workflows/playwright.yml +74 -0
  4. data/.github/workflows/rspec.yml +33 -0
  5. data/.node-version +1 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +218 -0
  9. data/Rakefile +4 -0
  10. data/docs/conditional-rendering.md +119 -0
  11. data/docs/lifecycle-hooks.md +75 -0
  12. data/docs/list-rendering.md +51 -0
  13. data/examples/Gemfile +5 -0
  14. data/examples/Gemfile.lock +41 -0
  15. data/examples/Makefile +15 -0
  16. data/examples/npm-packages/runtime/counter/index.html +28 -0
  17. data/examples/npm-packages/runtime/counter/index.rb +62 -0
  18. data/examples/npm-packages/runtime/counter/index.spec.js +42 -0
  19. data/examples/npm-packages/runtime/hello/index.html +28 -0
  20. data/examples/npm-packages/runtime/hello/index.rb +29 -0
  21. data/examples/npm-packages/runtime/hello/index.spec.js +53 -0
  22. data/examples/npm-packages/runtime/input/index.html +28 -0
  23. data/examples/npm-packages/runtime/input/index.rb +46 -0
  24. data/examples/npm-packages/runtime/input/index.spec.js +58 -0
  25. data/examples/npm-packages/runtime/list/index.html +27 -0
  26. data/examples/npm-packages/runtime/list/index.rb +33 -0
  27. data/examples/npm-packages/runtime/list/index.spec.js +46 -0
  28. data/examples/npm-packages/runtime/on_mounted_demo/index.html +40 -0
  29. data/examples/npm-packages/runtime/on_mounted_demo/index.rb +59 -0
  30. data/examples/npm-packages/runtime/on_mounted_demo/index.spec.js +50 -0
  31. data/examples/npm-packages/runtime/r_if_attribute_demo/index.html +34 -0
  32. data/examples/npm-packages/runtime/r_if_attribute_demo/index.rb +113 -0
  33. data/examples/npm-packages/runtime/r_if_attribute_demo/index.spec.js +140 -0
  34. data/examples/npm-packages/runtime/random_cocktail/index.html +27 -0
  35. data/examples/npm-packages/runtime/random_cocktail/index.rb +69 -0
  36. data/examples/npm-packages/runtime/random_cocktail/index.spec.js +101 -0
  37. data/examples/npm-packages/runtime/search_field/index.html +27 -0
  38. data/examples/npm-packages/runtime/search_field/index.rb +39 -0
  39. data/examples/npm-packages/runtime/search_field/index.spec.js +59 -0
  40. data/examples/npm-packages/runtime/todos/index.html +28 -0
  41. data/examples/npm-packages/runtime/todos/index.rb +239 -0
  42. data/examples/npm-packages/runtime/todos/index.spec.js +161 -0
  43. data/examples/npm-packages/runtime/todos/todos_repository.rb +23 -0
  44. data/examples/package.json +12 -0
  45. data/examples/src/counter/index.html +23 -0
  46. data/examples/src/counter/index.rb +60 -0
  47. data/lib/ruby_wasm_ui +1 -0
  48. data/lib/ruby_wasm_ui.rb +1 -0
  49. data/package-lock.json +100 -0
  50. data/package.json +32 -0
  51. data/packages/npm-packages/runtime/Gemfile +3 -0
  52. data/packages/npm-packages/runtime/Gemfile.lock +26 -0
  53. data/packages/npm-packages/runtime/README.md +5 -0
  54. data/packages/npm-packages/runtime/eslint.config.mjs +16 -0
  55. data/packages/npm-packages/runtime/package-lock.json +6668 -0
  56. data/packages/npm-packages/runtime/package.json +38 -0
  57. data/packages/npm-packages/runtime/rollup.config.mjs +89 -0
  58. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/component_spec.rb +416 -0
  59. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/dom/scheduler_spec.rb +98 -0
  60. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/nodes_equal_spec.rb +190 -0
  61. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/build_conditional_group_spec.rb +505 -0
  62. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/build_for_group_spec.rb +377 -0
  63. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/build_vdom_spec.rb +573 -0
  64. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/template/parser_spec.rb +627 -0
  65. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/arrays_spec.rb +228 -0
  66. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/objects_spec.rb +127 -0
  67. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/props_spec.rb +205 -0
  68. data/packages/npm-packages/runtime/spec/ruby_wasm_ui/utils/strings_spec.rb +107 -0
  69. data/packages/npm-packages/runtime/spec/spec_helper.rb +16 -0
  70. data/packages/npm-packages/runtime/src/__tests__/sample.test.js +5 -0
  71. data/packages/npm-packages/runtime/src/index.js +37 -0
  72. data/packages/npm-packages/runtime/src/ruby_wasm_ui/app.rb +53 -0
  73. data/packages/npm-packages/runtime/src/ruby_wasm_ui/component.rb +215 -0
  74. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dispatcher.rb +46 -0
  75. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/attributes.rb +105 -0
  76. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/destroy_dom.rb +63 -0
  77. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/events.rb +40 -0
  78. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/mount_dom.rb +108 -0
  79. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/patch_dom.rb +237 -0
  80. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom/scheduler.rb +51 -0
  81. data/packages/npm-packages/runtime/src/ruby_wasm_ui/dom.rb +13 -0
  82. data/packages/npm-packages/runtime/src/ruby_wasm_ui/nodes_equal.rb +45 -0
  83. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/build_conditional_group.rb +150 -0
  84. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/build_for_group.rb +125 -0
  85. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/build_vdom.rb +220 -0
  86. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template/parser.rb +134 -0
  87. data/packages/npm-packages/runtime/src/ruby_wasm_ui/template.rb +11 -0
  88. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/arrays.rb +185 -0
  89. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/objects.rb +37 -0
  90. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/props.rb +25 -0
  91. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils/strings.rb +19 -0
  92. data/packages/npm-packages/runtime/src/ruby_wasm_ui/utils.rb +11 -0
  93. data/packages/npm-packages/runtime/src/ruby_wasm_ui/vdom.rb +84 -0
  94. data/packages/npm-packages/runtime/src/ruby_wasm_ui/version.rb +5 -0
  95. data/packages/npm-packages/runtime/src/ruby_wasm_ui.rb +14 -0
  96. data/packages/npm-packages/runtime/vitest.config.js +8 -0
  97. data/playwright.config.js +78 -0
  98. data/sig/ruby_wasm_ui.rbs +4 -0
  99. metadata +168 -0
@@ -0,0 +1,627 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyWasmUi::Template::Parser do
6
+ describe '.preprocess_pascal_case_component_name' do
7
+ context 'when processing PascalCase component names' do
8
+ it 'converts simple PascalCase component name' do
9
+ input = '<ButtonComponent>Click me</ButtonComponent>'
10
+ expected = '<button-component>Click me</button-component>'
11
+ result = described_class.preprocess_pascal_case_component_name(input)
12
+ expect(result).to eq(expected)
13
+ end
14
+
15
+ it 'converts component with attributes' do
16
+ input = '<ButtonComponent class="primary" disabled>Click me</ButtonComponent>'
17
+ expected = '<button-component class="primary" disabled>Click me</button-component>'
18
+ result = described_class.preprocess_pascal_case_component_name(input)
19
+ expect(result).to eq(expected)
20
+ end
21
+
22
+ it 'converts self-closing component' do
23
+ input = '<SearchField placeholder="Search..." />'
24
+ expected = '<search-field placeholder="Search..." />'
25
+ result = described_class.preprocess_pascal_case_component_name(input)
26
+ expect(result).to eq(expected)
27
+ end
28
+
29
+ it 'converts multiple components in the same template' do
30
+ input = '<ButtonComponent><SearchField /><IconComponent name="search" /></ButtonComponent>'
31
+ expected = '<button-component><search-field /><icon-component name="search" /></button-component>'
32
+ result = described_class.preprocess_pascal_case_component_name(input)
33
+ expect(result).to eq(expected)
34
+ end
35
+
36
+ it 'converts components with complex PascalCase names' do
37
+ input = '<TodoListItemComponent><UserProfileCardComponent /></TodoListItemComponent>'
38
+ expected = '<todo-list-item-component><user-profile-card-component /></todo-list-item-component>'
39
+ result = described_class.preprocess_pascal_case_component_name(input)
40
+ expect(result).to eq(expected)
41
+ end
42
+
43
+ it 'handles components with embedded Ruby expressions' do
44
+ input = '<ButtonComponent on="{ click: ->(e) { handle_click(e) } }">Count: {count}</ButtonComponent>'
45
+ expected = '<button-component on="{ click: ->(e) { handle_click(e) } }">Count: {count}</button-component>'
46
+ result = described_class.preprocess_pascal_case_component_name(input)
47
+ expect(result).to eq(expected)
48
+ end
49
+ end
50
+
51
+ context 'when processing mixed content' do
52
+ it 'does not convert regular HTML elements' do
53
+ input = '<div><span>Text</span><ButtonComponent>Click me</ButtonComponent></div>'
54
+ expected = '<div><span>Text</span><button-component>Click me</button-component></div>'
55
+ result = described_class.preprocess_pascal_case_component_name(input)
56
+ expect(result).to eq(expected)
57
+ end
58
+
59
+ it 'preserves existing kebab-case components' do
60
+ input = '<custom-button><ButtonComponent>Click me</ButtonComponent></custom-button>'
61
+ expected = '<custom-button><button-component>Click me</button-component></custom-button>'
62
+ result = described_class.preprocess_pascal_case_component_name(input)
63
+ expect(result).to eq(expected)
64
+ end
65
+ end
66
+ end
67
+
68
+ describe '.preprocess_template_tag' do
69
+ context 'when processing template tags' do
70
+ it 'converts simple template tag' do
71
+ input = '<template>Hello World</template>'
72
+ expected = '<div data-template>Hello World</div>'
73
+ result = described_class.preprocess_template_tag(input)
74
+ expect(result).to eq(expected)
75
+ end
76
+
77
+ it 'converts template tag with attributes' do
78
+ input = '<template class="container" data-test="value">Content</template>'
79
+ expected = '<div data-template class="container" data-test="value">Content</div>'
80
+ result = described_class.preprocess_template_tag(input)
81
+ expect(result).to eq(expected)
82
+ end
83
+
84
+ it 'converts nested template tags' do
85
+ input = '<template><template>Nested</template></template>'
86
+ expected = '<div data-template><div data-template>Nested</div></div>'
87
+ result = described_class.preprocess_template_tag(input)
88
+ expect(result).to eq(expected)
89
+ end
90
+
91
+ it 'converts template tags with complex content' do
92
+ input = '<template><div>Text</div><ButtonComponent>Click</ButtonComponent></template>'
93
+ expected = '<div data-template><div>Text</div><ButtonComponent>Click</ButtonComponent></div>'
94
+ result = described_class.preprocess_template_tag(input)
95
+ expect(result).to eq(expected)
96
+ end
97
+
98
+ it 'preserves whitespace and indentation' do
99
+ input = " <template>\n <div>Content</div>\n </template>"
100
+ expected = " <div data-template>\n <div>Content</div>\n </div>"
101
+ result = described_class.preprocess_template_tag(input)
102
+ expect(result).to eq(expected)
103
+ end
104
+ end
105
+ end
106
+
107
+ describe '.preprocess_self_closing_tags' do
108
+ context 'when processing custom elements with self-closing tags' do
109
+ it 'converts simple custom element self-closing tag' do
110
+ input = '<search-field value="test" />'
111
+ expected = '<search-field value="test" ></search-field>'
112
+ result = described_class.preprocess_self_closing_tags(input)
113
+ expect(result).to eq(expected)
114
+ end
115
+
116
+ it 'converts custom element with multiple attributes' do
117
+ input = '<my-component id="1" class="test" data-value="123" />'
118
+ expected = '<my-component id="1" class="test" data-value="123" ></my-component>'
119
+ result = described_class.preprocess_self_closing_tags(input)
120
+ expect(result).to eq(expected)
121
+ end
122
+
123
+ it 'converts multiple custom elements' do
124
+ input = '<first-component /><second-component attr="value" />'
125
+ expected = '<first-component ></first-component><second-component attr="value" ></second-component>'
126
+ result = described_class.preprocess_self_closing_tags(input)
127
+ expect(result).to eq(expected)
128
+ end
129
+
130
+ it 'handles custom elements with complex attributes including lambdas' do
131
+ input = '<search-field value="{component.state[:search_term]}" on="{ search: ->(term) { update(term) } }" />'
132
+ expected = '<search-field value="{component.state[:search_term]}" on="{ search: ->(term) { update(term) } }" ></search-field>'
133
+ result = described_class.preprocess_self_closing_tags(input)
134
+ expect(result).to eq(expected)
135
+ end
136
+
137
+ it 'handles nested quotes in attributes' do
138
+ input = '<my-component data-json=\'{"key": "value"}\' />'
139
+ expected = '<my-component data-json=\'{"key": "value"}\' ></my-component>'
140
+ result = described_class.preprocess_self_closing_tags(input)
141
+ expect(result).to eq(expected)
142
+ end
143
+ end
144
+
145
+ context 'when processing PascalCase components with self-closing tags' do
146
+ it 'does not convert PascalCase component self-closing tag (handled by pascal case preprocessor)' do
147
+ input = '<TodoItemEditComponent edited="test" />'
148
+ expected = '<TodoItemEditComponent edited="test" />'
149
+ result = described_class.preprocess_self_closing_tags(input)
150
+ expect(result).to eq(expected)
151
+ end
152
+
153
+ it 'does not convert PascalCase component with multiple attributes (handled by pascal case preprocessor)' do
154
+ input = '<SearchFieldComponent id="1" placeholder="Search..." value="test" />'
155
+ expected = '<SearchFieldComponent id="1" placeholder="Search..." value="test" />'
156
+ result = described_class.preprocess_self_closing_tags(input)
157
+ expect(result).to eq(expected)
158
+ end
159
+
160
+ it 'does not convert multiple PascalCase components (handled by pascal case preprocessor)' do
161
+ input = '<ButtonComponent /><InputComponent type="text" />'
162
+ expected = '<ButtonComponent /><InputComponent type="text" />'
163
+ result = described_class.preprocess_self_closing_tags(input)
164
+ expect(result).to eq(expected)
165
+ end
166
+
167
+ it 'does not handle PascalCase components with complex attributes (handled by pascal case preprocessor)' do
168
+ input = '<TodoItemEditComponent edited="{component.state[:edited]}" on="{ save: ->() { component.save_edition } }" />'
169
+ expected = '<TodoItemEditComponent edited="{component.state[:edited]}" on="{ save: ->() { component.save_edition } }" />'
170
+ result = described_class.preprocess_self_closing_tags(input)
171
+ expect(result).to eq(expected)
172
+ end
173
+
174
+ it 'does not handle PascalCase components with r-if attributes (handled by pascal case preprocessor)' do
175
+ input = '<TodoItemEditComponent r-if="{component.state[:is_editing]}" edited="{component.state[:edited]}" />'
176
+ expected = '<TodoItemEditComponent r-if="{component.state[:is_editing]}" edited="{component.state[:edited]}" />'
177
+ result = described_class.preprocess_self_closing_tags(input)
178
+ expect(result).to eq(expected)
179
+ end
180
+ end
181
+
182
+ context 'when processing standard HTML elements' do
183
+ it 'does not convert standard HTML void elements' do
184
+ input = '<input type="text" />'
185
+ expected = '<input type="text" />'
186
+ result = described_class.preprocess_self_closing_tags(input)
187
+ expect(result).to eq(expected)
188
+ end
189
+
190
+ it 'does not convert single word tags' do
191
+ input = '<div /><span />'
192
+ expected = '<div /><span />'
193
+ result = described_class.preprocess_self_closing_tags(input)
194
+ expect(result).to eq(expected)
195
+ end
196
+
197
+ it 'does not convert already closed custom elements' do
198
+ input = '<my-component></my-component>'
199
+ expected = '<my-component></my-component>'
200
+ result = described_class.preprocess_self_closing_tags(input)
201
+ expect(result).to eq(expected)
202
+ end
203
+ end
204
+
205
+ context 'when processing mixed content' do
206
+ it 'converts only custom elements in mixed HTML' do
207
+ input = '<div><input type="text" /><my-component /></div>'
208
+ expected = '<div><input type="text" /><my-component ></my-component></div>'
209
+ result = described_class.preprocess_self_closing_tags(input)
210
+ expect(result).to eq(expected)
211
+ end
212
+
213
+ it 'handles multiple custom elements with different naming patterns' do
214
+ input = '<search-field /><todo-list-item /><app-header-nav />'
215
+ expected = '<search-field ></search-field><todo-list-item ></todo-list-item><app-header-nav ></app-header-nav>'
216
+ result = described_class.preprocess_self_closing_tags(input)
217
+ expect(result).to eq(expected)
218
+ end
219
+ end
220
+ end
221
+
222
+ describe '.parse' do
223
+ let(:mock_parser) { double('parser') }
224
+ let(:mock_document) { double('document') }
225
+ let(:mock_body) { double('body') }
226
+ let(:mock_child_nodes) { double('child_nodes') }
227
+
228
+ before do
229
+ # Mock preprocessing chain in the correct order
230
+ allow(described_class).to receive(:preprocess_template_tag).with('template_string').and_return('after_template_processing')
231
+ allow(described_class).to receive(:preprocess_pascal_case_component_name).with('after_template_processing').and_return('after_pascal_processing')
232
+ allow(described_class).to receive(:preprocess_self_closing_tags).with('after_pascal_processing').and_return('processed_template')
233
+
234
+ # Mock JS.eval and DOMParser
235
+ js_mock = double('JS')
236
+ allow(js_mock).to receive(:eval).with('return new DOMParser()').and_return(mock_parser)
237
+ allow(js_mock).to receive(:try_convert).with('processed_template').and_return('processed_template')
238
+ stub_const('JS', js_mock)
239
+
240
+ allow(mock_parser).to receive(:call).with(:parseFromString, 'processed_template', 'text/html').and_return(mock_document)
241
+ allow(mock_document).to receive(:getElementsByTagName).with('body').and_return([mock_body])
242
+ allow(mock_body).to receive(:[]).with(:childNodes).and_return(mock_child_nodes)
243
+
244
+ # Mock empty child nodes
245
+ allow(mock_child_nodes).to receive(:[]).with(:length).and_return(0)
246
+ end
247
+
248
+ it 'preprocesses template, pascal case, then self-closing tags in correct order' do
249
+ expect(described_class).to receive(:preprocess_template_tag).with('template_string').and_return('after_template_processing')
250
+ expect(described_class).to receive(:preprocess_pascal_case_component_name).with('after_template_processing').and_return('after_pascal_processing')
251
+ expect(described_class).to receive(:preprocess_self_closing_tags).with('after_pascal_processing').and_return('processed_template')
252
+ described_class.parse('template_string')
253
+ end
254
+
255
+ it 'parses HTML template and returns VDOM string' do
256
+ result = described_class.parse('template_string')
257
+ expect(result).to eq('')
258
+ end
259
+
260
+ context 'when processing template tags' do
261
+ it 'replaces <template> with <div data-template>' do
262
+ template = '<template><div>content</div></template>'
263
+ expected_replacement = '<div data-template><div>content</div></div>'
264
+
265
+ # Mock the preprocessing chain
266
+ allow(described_class).to receive(:preprocess_template_tag).with(template).and_return(expected_replacement)
267
+ allow(described_class).to receive(:preprocess_pascal_case_component_name).with(expected_replacement).and_return(expected_replacement)
268
+ allow(described_class).to receive(:preprocess_self_closing_tags).with(expected_replacement).and_return(expected_replacement)
269
+
270
+ # Mock JS components to avoid actual parsing
271
+ js_mock = double('JS')
272
+ allow(js_mock).to receive(:eval).and_return(mock_parser)
273
+ allow(js_mock).to receive(:try_convert).with(expected_replacement).and_return(expected_replacement)
274
+ stub_const('JS', js_mock)
275
+
276
+ allow(mock_parser).to receive(:call).and_return(mock_document)
277
+ allow(mock_document).to receive(:getElementsByTagName).and_return([mock_body])
278
+ allow(mock_body).to receive(:[]).and_return(mock_child_nodes)
279
+ allow(mock_child_nodes).to receive(:[]).with(:length).and_return(0)
280
+
281
+ described_class.parse(template)
282
+
283
+ # Verify that JS.try_convert was called with the replaced template
284
+ expect(js_mock).to have_received(:try_convert).with(expected_replacement)
285
+ end
286
+
287
+ it 'replaces <template attr="value"> with <div data-template attr="value">' do
288
+ template = '<template class="container"><div>content</div></template>'
289
+ expected_replacement = '<div data-template class="container"><div>content</div></div>'
290
+
291
+ # Mock the preprocessing chain
292
+ allow(described_class).to receive(:preprocess_template_tag).with(template).and_return(expected_replacement)
293
+ allow(described_class).to receive(:preprocess_pascal_case_component_name).with(expected_replacement).and_return(expected_replacement)
294
+ allow(described_class).to receive(:preprocess_self_closing_tags).with(expected_replacement).and_return(expected_replacement)
295
+
296
+ # Mock JS components to avoid actual parsing
297
+ js_mock = double('JS')
298
+ allow(js_mock).to receive(:eval).and_return(mock_parser)
299
+ allow(js_mock).to receive(:try_convert).with(expected_replacement).and_return(expected_replacement)
300
+ stub_const('JS', js_mock)
301
+
302
+ allow(mock_parser).to receive(:call).and_return(mock_document)
303
+ allow(mock_document).to receive(:getElementsByTagName).and_return([mock_body])
304
+ allow(mock_body).to receive(:[]).and_return(mock_child_nodes)
305
+ allow(mock_child_nodes).to receive(:[]).with(:length).and_return(0)
306
+
307
+ described_class.parse(template)
308
+
309
+ # Verify that JS.try_convert was called with the replaced template
310
+ expect(js_mock).to have_received(:try_convert).with(expected_replacement)
311
+ end
312
+ end
313
+ end
314
+
315
+ describe '.parse_and_eval' do
316
+ let(:mock_parser) { double('parser') }
317
+ let(:mock_document) { double('document') }
318
+ let(:mock_body) { double('body') }
319
+ let(:mock_child_nodes) { double('child_nodes') }
320
+ let(:mock_element) { double('element') }
321
+ let(:mock_vdom) { double('vdom') }
322
+
323
+ before do
324
+ # Mock preprocessing chain
325
+ allow(described_class).to receive(:preprocess_template_tag).with(anything).and_return('after_template_processing')
326
+ allow(described_class).to receive(:preprocess_pascal_case_component_name).with(anything).and_return('after_pascal_processing')
327
+ allow(described_class).to receive(:preprocess_self_closing_tags).with(anything).and_return('processed_template')
328
+
329
+ # Mock JS.eval, DOMParser, and JS.global
330
+ js_mock = double('JS')
331
+ allow(js_mock).to receive(:eval).with('return new DOMParser()').and_return(mock_parser)
332
+ allow(js_mock).to receive(:try_convert).with('processed_template').and_return('processed_template')
333
+ allow(js_mock).to receive(:global).and_return({ Node: { TEXT_NODE: 3, ELEMENT_NODE: 1 } })
334
+ stub_const('JS', js_mock)
335
+
336
+ allow(mock_parser).to receive(:call).with(:parseFromString, 'processed_template', 'text/html').and_return(mock_document)
337
+ allow(mock_document).to receive(:getElementsByTagName).with('body').and_return([mock_body])
338
+ allow(mock_body).to receive(:[]).with(:childNodes).and_return(mock_child_nodes)
339
+
340
+ # Mock element node
341
+ allow(mock_element).to receive(:[]).with(:nodeType).and_return(1) # ELEMENT_NODE
342
+ allow(mock_element).to receive(:[]).with(:tagName).and_return('DIV')
343
+ allow(mock_element).to receive(:[]).with(:attributes).and_return({ length: 0 })
344
+ allow(mock_element).to receive(:[]).with(:childNodes).and_return([])
345
+
346
+ # Mock child nodes as a JavaScript-like array with length and indexing
347
+ mock_child_nodes_array = double('child_nodes_array')
348
+ allow(mock_child_nodes_array).to receive(:[]).with(:length).and_return(1)
349
+ allow(mock_child_nodes_array).to receive(:[]).with(0).and_return(mock_element)
350
+ allow(mock_body).to receive(:[]).with(:childNodes).and_return(mock_child_nodes_array)
351
+
352
+ # Mock empty child nodes for element
353
+ mock_empty_nodes = double('empty_nodes')
354
+ allow(mock_empty_nodes).to receive(:[]).with(:length).and_return(0)
355
+ allow(mock_element).to receive(:[]).with(:childNodes).and_return(mock_empty_nodes)
356
+ end
357
+
358
+ context 'when evaluating a template with variables' do
359
+ it 'returns a VDOM object with evaluated variables' do
360
+ template = '<div>{count}</div>'
361
+ count = 42 # rubocop:disable Lint/UselessAssignment
362
+ test_binding = binding()
363
+
364
+ # Mock element node
365
+ allow(mock_element).to receive(:[]).with(:nodeType).and_return(1) # ELEMENT_NODE
366
+ allow(mock_element).to receive(:[]).with(:tagName).and_return('DIV')
367
+ mock_element_attributes = double('element_attributes')
368
+ allow(mock_element_attributes).to receive(:to_a).and_return([])
369
+ allow(mock_element_attributes).to receive(:[]).with(:length).and_return(0)
370
+ allow(mock_element).to receive(:[]).with(:attributes).and_return(mock_element_attributes)
371
+
372
+ # Mock text node for count
373
+ mock_text_node = double('text_node')
374
+ allow(mock_text_node).to receive(:[]).with(:nodeType).and_return(3) # TEXT_NODE
375
+ allow(mock_text_node).to receive(:[]).with(:nodeValue).and_return('{count}')
376
+ mock_child_nodes = double('child_nodes')
377
+ allow(mock_child_nodes).to receive(:[]).with(:length).and_return(1)
378
+ allow(mock_child_nodes).to receive(:[]).with(0).and_return(mock_text_node)
379
+ allow(mock_element).to receive(:[]).with(:childNodes).and_return(mock_child_nodes)
380
+
381
+ # Mock RubyWasmUi::Vdom
382
+ stub_const('RubyWasmUi::Vdom', mock_vdom)
383
+ allow(mock_vdom).to receive(:h).with('div', {}, ["42"]).and_return(mock_vdom)
384
+ allow(mock_vdom).to receive(:h_fragment).with([mock_vdom]).and_return(mock_vdom)
385
+
386
+ result = described_class.parse_and_eval(template, test_binding)
387
+ expect(result).to eq(mock_vdom)
388
+ end
389
+ end
390
+
391
+ context 'when evaluating a template with event handlers' do
392
+ it 'returns a VDOM object with event handlers' do
393
+ template = '<div on="{click: ->(e) { handle_click.call(e) }}"></div>'
394
+ handle_click = -> { 'clicked' } # rubocop:disable Lint/UselessAssignment
395
+ test_binding = binding()
396
+
397
+ # Mock element node with attributes
398
+ allow(mock_element).to receive(:[]).with(:nodeType).and_return(1) # ELEMENT_NODE
399
+ allow(mock_element).to receive(:[]).with(:tagName).and_return('DIV')
400
+ mock_attributes = double('attributes')
401
+ allow(mock_attributes).to receive(:[]).with(:length).and_return(1)
402
+ mock_attribute = double('attribute')
403
+ allow(mock_attributes).to receive(:[]).with(0).and_return(mock_attribute)
404
+ allow(mock_attribute).to receive(:[]).with(:name).and_return('on')
405
+ allow(mock_attribute).to receive(:[]).with(:value).and_return('{click: ->(e) { handle_click.call(e) }}')
406
+ allow(mock_attributes).to receive(:to_a).and_return([mock_attribute])
407
+ allow(mock_element).to receive(:[]).with(:attributes).and_return(mock_attributes)
408
+
409
+ # Mock RubyWasmUi::Vdom
410
+ stub_const('RubyWasmUi::Vdom', mock_vdom)
411
+ allow(mock_vdom).to receive(:h).with('div', { on: { click: kind_of(Proc) } }, []).and_return(mock_vdom)
412
+ allow(mock_vdom).to receive(:h_fragment).with([mock_vdom]).and_return(mock_vdom)
413
+
414
+ result = described_class.parse_and_eval(template, test_binding)
415
+ expect(result).to eq(mock_vdom)
416
+ end
417
+ end
418
+
419
+ context 'when evaluating a template with components' do
420
+ let(:mock_component) { double('component') }
421
+
422
+ it 'returns a VDOM object with components' do
423
+ template = '<custom-component>{count}</custom-component>'
424
+ count = 42 # rubocop:disable Lint/UselessAssignment
425
+ test_binding = binding()
426
+
427
+ # Mock component node
428
+ allow(mock_element).to receive(:[]).with(:nodeType).and_return(1) # ELEMENT_NODE
429
+ allow(mock_element).to receive(:[]).with(:tagName).and_return('CUSTOM-COMPONENT')
430
+ mock_component_attributes = double('component_attributes')
431
+ allow(mock_component_attributes).to receive(:to_a).and_return([])
432
+ allow(mock_component_attributes).to receive(:[]).with(:length).and_return(0)
433
+ allow(mock_element).to receive(:[]).with(:attributes).and_return(mock_component_attributes)
434
+
435
+ # Mock text node for count
436
+ mock_text_node = double('text_node')
437
+ allow(mock_text_node).to receive(:[]).with(:nodeType).and_return(3) # TEXT_NODE
438
+ allow(mock_text_node).to receive(:[]).with(:nodeValue).and_return('{count}')
439
+ mock_child_nodes = double('child_nodes')
440
+ allow(mock_child_nodes).to receive(:[]).with(:length).and_return(1)
441
+ allow(mock_child_nodes).to receive(:[]).with(0).and_return(mock_text_node)
442
+ allow(mock_element).to receive(:[]).with(:childNodes).and_return(mock_child_nodes)
443
+
444
+ # Mock component class
445
+ stub_const('CustomComponent', mock_component)
446
+
447
+ # Mock RubyWasmUi::Vdom
448
+ stub_const('RubyWasmUi::Vdom', mock_vdom)
449
+ allow(mock_vdom).to receive(:h).with(mock_component, {}, ["42"]).and_return(mock_vdom)
450
+ allow(mock_vdom).to receive(:h_fragment).with([mock_vdom]).and_return(mock_vdom)
451
+
452
+ result = described_class.parse_and_eval(template, test_binding)
453
+ expect(result).to eq(mock_vdom)
454
+ end
455
+ end
456
+
457
+ context 'when evaluating multiple top-level expressions' do
458
+ it 'wraps multiple expressions in a fragment' do
459
+ # Mock parse to return multiple expressions
460
+ allow(described_class).to receive(:parse).and_return('expr1,expr2')
461
+
462
+ # Mock eval to return the fragment
463
+ mock_fragment = double('fragment')
464
+ allow(described_class).to receive(:eval).with('RubyWasmUi::Vdom.h_fragment([expr1,expr2])', anything).and_return(mock_fragment)
465
+
466
+ result = described_class.parse_and_eval('<div>1</div><div>2</div>', binding)
467
+ expect(result).to eq(mock_fragment)
468
+ end
469
+
470
+ it 'wraps expressions containing end, in a fragment' do
471
+ # Mock parse to return expressions with 'end,'
472
+ allow(described_class).to receive(:parse).and_return('if condition then expr1 end,expr2')
473
+
474
+ # Mock eval to return the fragment
475
+ mock_fragment = double('fragment')
476
+ allow(described_class).to receive(:eval).with('RubyWasmUi::Vdom.h_fragment([if condition then expr1 end,expr2])', anything).and_return(mock_fragment)
477
+
478
+ result = described_class.parse_and_eval('<div r-if="{true}">content</div><div>other</div>', binding)
479
+ expect(result).to eq(mock_fragment)
480
+ end
481
+
482
+ it 'does not wrap single expression' do
483
+ # Mock parse to return single expression
484
+ allow(described_class).to receive(:parse).and_return('single_expr')
485
+
486
+ # Mock eval to return the expression directly
487
+ mock_result = double('result')
488
+ allow(described_class).to receive(:eval).with('single_expr', anything).and_return(mock_result)
489
+
490
+ result = described_class.parse_and_eval('<div>single</div>', binding)
491
+ expect(result).to eq(mock_result)
492
+ end
493
+ end
494
+ end
495
+
496
+ describe 'integration tests for preprocessing chain' do
497
+ context 'when processing PascalCase components with self-closing tags' do
498
+ it 'converts PascalCase to kebab-case then handles self-closing tags' do
499
+ template = '<SearchField placeholder="test" />'
500
+
501
+ # This should first convert to kebab-case, then handle self-closing
502
+ result = described_class.preprocess_pascal_case_component_name(template)
503
+ expect(result).to eq('<search-field placeholder="test" />')
504
+
505
+ # Then the self-closing tag should be processed
506
+ final_result = described_class.preprocess_self_closing_tags(result)
507
+ expect(final_result).to eq('<search-field placeholder="test" ></search-field>')
508
+ end
509
+
510
+ it 'handles complex PascalCase components with attributes' do
511
+ template = '<TodoItemEditComponent r-if="{editing}" edited="{value}" on="{ save: ->() { save() } }" />'
512
+
513
+ # First convert to kebab-case
514
+ result = described_class.preprocess_pascal_case_component_name(template)
515
+ expect(result).to eq('<todo-item-edit-component r-if="{editing}" edited="{value}" on="{ save: ->() { save() } }" />')
516
+
517
+ # Then handle self-closing tag
518
+ final_result = described_class.preprocess_self_closing_tags(result)
519
+ expect(final_result).to eq('<todo-item-edit-component r-if="{editing}" edited="{value}" on="{ save: ->() { save() } }" ></todo-item-edit-component>')
520
+ end
521
+
522
+ it 'handles template tags with PascalCase components' do
523
+ template = '<template><ButtonComponent>Click me</ButtonComponent></template>'
524
+
525
+ # First process template tag
526
+ result = described_class.preprocess_template_tag(template)
527
+ expect(result).to eq('<div data-template><ButtonComponent>Click me</ButtonComponent></div>')
528
+
529
+ # Then convert PascalCase
530
+ result = described_class.preprocess_pascal_case_component_name(result)
531
+ expect(result).to eq('<div data-template><button-component>Click me</button-component></div>')
532
+
533
+ # Self-closing processing doesn't affect this case
534
+ final_result = described_class.preprocess_self_closing_tags(result)
535
+ expect(final_result).to eq('<div data-template><button-component>Click me</button-component></div>')
536
+ end
537
+
538
+ it 'handles PascalCase components with r-for attributes' do
539
+ template = '<TodoItemComponent r-for="{todo in todos}" key="{todo[:text]}" />'
540
+
541
+ # First convert to kebab-case
542
+ result = described_class.preprocess_pascal_case_component_name(template)
543
+ expect(result).to eq('<todo-item-component r-for="{todo in todos}" key="{todo[:text]}" />')
544
+
545
+ # Then handle self-closing tag
546
+ final_result = described_class.preprocess_self_closing_tags(result)
547
+ expect(final_result).to eq('<todo-item-component r-for="{todo in todos}" key="{todo[:text]}" ></todo-item-component>')
548
+ end
549
+ end
550
+
551
+ context 'when processing r-for attributes in templates' do
552
+ let(:mock_parser) { double('parser') }
553
+ let(:mock_document) { double('document') }
554
+ let(:mock_body) { double('body') }
555
+ let(:mock_child_nodes) { double('child_nodes') }
556
+ let(:mock_element) { double('element') }
557
+ let(:mock_attributes) { double('attributes') }
558
+ let(:mock_for_attribute) { double('for_attribute') }
559
+ let(:mock_key_attribute) { double('key_attribute') }
560
+
561
+ before do
562
+ # Mock preprocessing chain
563
+ allow(described_class).to receive(:preprocess_template_tag).with(anything).and_return('after_template_processing')
564
+ allow(described_class).to receive(:preprocess_pascal_case_component_name).with(anything).and_return('after_pascal_processing')
565
+ allow(described_class).to receive(:preprocess_self_closing_tags).with(anything).and_return('processed_template')
566
+
567
+ # Mock JS.eval, DOMParser, and JS.global
568
+ js_mock = double('JS')
569
+ allow(js_mock).to receive(:eval).with('return new DOMParser()').and_return(mock_parser)
570
+ allow(js_mock).to receive(:try_convert).with('processed_template').and_return('processed_template')
571
+ allow(js_mock).to receive(:global).and_return({ Node: { TEXT_NODE: 3, ELEMENT_NODE: 1 } })
572
+ stub_const('JS', js_mock)
573
+
574
+ allow(mock_parser).to receive(:call).with(:parseFromString, 'processed_template', 'text/html').and_return(mock_document)
575
+ allow(mock_document).to receive(:getElementsByTagName).with('body').and_return([mock_body])
576
+ allow(mock_body).to receive(:[]).with(:childNodes).and_return(mock_child_nodes)
577
+
578
+ # Mock element with r-for attribute
579
+ allow(mock_element).to receive(:[]).with(:nodeType).and_return(1) # ELEMENT_NODE
580
+ allow(mock_element).to receive(:[]).with(:tagName).and_return('TODO-ITEM-COMPONENT')
581
+ allow(mock_element).to receive(:[]).with(:attributes).and_return(mock_attributes)
582
+ allow(mock_element).to receive(:[]).with(:childNodes).and_return([])
583
+
584
+ # Mock r-for and key attributes
585
+ allow(mock_attributes).to receive(:[]).with(:length).and_return(2)
586
+ allow(mock_attributes).to receive(:[]).with(0).and_return(mock_for_attribute)
587
+ allow(mock_attributes).to receive(:[]).with(1).and_return(mock_key_attribute)
588
+ allow(mock_for_attribute).to receive(:[]).with(:name).and_return('r-for')
589
+ allow(mock_for_attribute).to receive(:[]).with(:value).and_return('{todo in todos}')
590
+ allow(mock_key_attribute).to receive(:[]).with(:name).and_return('key')
591
+ allow(mock_key_attribute).to receive(:[]).with(:value).and_return('{todo[:text]}')
592
+
593
+ # Mock child nodes as a JavaScript-like array with length and indexing
594
+ allow(mock_child_nodes).to receive(:[]).with(:length).and_return(1)
595
+ allow(mock_child_nodes).to receive(:[]).with(0).and_return(mock_element)
596
+
597
+ # Mock empty child nodes for element
598
+ mock_empty_nodes = double('empty_nodes')
599
+ allow(mock_empty_nodes).to receive(:[]).with(:length).and_return(0)
600
+ allow(mock_element).to receive(:[]).with(:childNodes).and_return(mock_empty_nodes)
601
+
602
+ # Mock BuildForGroup and BuildConditionalGroup
603
+ allow(RubyWasmUi::Template::BuildConditionalGroup).to receive(:has_conditional_attribute?).with(mock_element).and_return(false)
604
+ allow(RubyWasmUi::Template::BuildForGroup).to receive(:has_for_attribute?).with(mock_element).and_return(true)
605
+ allow(RubyWasmUi::Template::BuildForGroup).to receive(:build_for_loop).with(mock_element).and_return("todos.map do |todo|\n RubyWasmUi::Vdom.h(TodoItemComponent, {:key => todo[:text]}, [])\nend")
606
+ end
607
+
608
+ it 'processes r-for attributes and generates map code' do
609
+ template = '<ul><TodoItemComponent r-for="{todo in todos}" key="{todo[:text]}" /></ul>'
610
+
611
+ result = described_class.parse(template)
612
+ expected = "*todos.map do |todo|\n RubyWasmUi::Vdom.h(TodoItemComponent, {:key => todo[:text]}, [])\nend"
613
+ expect(result).to eq(expected)
614
+ end
615
+
616
+ it 'evaluates r-for templates with variables' do
617
+ template = '<ul><TodoItemComponent r-for="{todo in todos}" key="{todo[:text]}" /></ul>'
618
+
619
+ # Just test that parse returns the expected code structure
620
+ result = described_class.parse(template)
621
+ expect(result).to include('*todos.map do |todo|')
622
+ expect(result).to include('RubyWasmUi::Vdom.h(TodoItemComponent')
623
+ expect(result).to include('end')
624
+ end
625
+ end
626
+ end
627
+ end