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,38 @@
1
+ {
2
+ "name": "ruby-wasm-ui",
3
+ "version": "0.8.1",
4
+ "description": "",
5
+ "main": "dist/ruby-wasm-ui.js",
6
+ "files": [
7
+ "dist/ruby-wasm-ui.js",
8
+ "dist/ruby_wasm_ui.rb",
9
+ "dist/ruby_wasm_ui/**/*.rb"
10
+ ],
11
+ "scripts": {
12
+ "prepack": "npm run build",
13
+ "build": "NODE_ENV=production rollup -c",
14
+ "build:dev": "NODE_ENV=development rollup -c",
15
+ "lint": "eslint src",
16
+ "lint:fix": "eslint src --fix",
17
+ "test": "vitest",
18
+ "test:run": "vitest run"
19
+ },
20
+ "keywords": [
21
+ "ruby",
22
+ "wasm",
23
+ "ui"
24
+ ],
25
+ "author": "t0yohei <k.t0yohei@gmail.com>",
26
+ "license": "MIT",
27
+ "type": "module",
28
+ "devDependencies": {
29
+ "@rollup/plugin-replace": "^6.0.2",
30
+ "eslint": "^9.25.1",
31
+ "jsdom": "^26.1.0",
32
+ "rollup": "^4.40.0",
33
+ "rollup-plugin-cleanup": "^3.2.1",
34
+ "rollup-plugin-copy": "^3.5.0",
35
+ "rollup-plugin-filesize": "^10.0.0",
36
+ "vitest": "^3.1.2"
37
+ }
38
+ }
@@ -0,0 +1,89 @@
1
+ import copy from "rollup-plugin-copy";
2
+ import cleanup from "rollup-plugin-cleanup";
3
+ import filesize from "rollup-plugin-filesize";
4
+ import replace from "@rollup/plugin-replace";
5
+ import { glob } from "glob";
6
+ import process from "process";
7
+ import { readFileSync, writeFileSync } from "fs";
8
+ import { join, dirname } from "path";
9
+ import { fileURLToPath } from "url";
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+
13
+ // Find all Ruby files in ruby_wasm_ui directory
14
+ const rubyFiles = glob
15
+ .sync("src/ruby_wasm_ui/**/*.rb")
16
+ .map((file) => file.replace("src/", ""))
17
+ .sort((a, b) => {
18
+ // Files in root directory should be loaded first
19
+ const aIsRoot = !a.includes("/");
20
+ const bIsRoot = !b.includes("/");
21
+ if (aIsRoot && !bIsRoot) return -1;
22
+ if (!aIsRoot && bIsRoot) return 1;
23
+
24
+ // Then sort by directory and filename
25
+ return a.localeCompare(b);
26
+ });
27
+
28
+ // Determine environment based on NODE_ENV
29
+ const isDevelopment = process.env.NODE_ENV === "development";
30
+
31
+ // Plugin to remove require_relative lines from Ruby files
32
+ const removeRequireRelative = () => {
33
+ return {
34
+ name: "remove-require-relative",
35
+ writeBundle() {
36
+ // Process all Ruby files in dist directory recursively
37
+ const distRubyFiles = glob.sync("dist/**/*.rb");
38
+
39
+ distRubyFiles.forEach((file) => {
40
+ const filePath = join(__dirname, file);
41
+ let content = readFileSync(filePath, "utf-8");
42
+ // Remove lines that start with require_relative (with optional whitespace)
43
+ content = content.replace(/^\s*require_relative\s+.*$/gm, "");
44
+ // Remove multiple consecutive empty lines
45
+ content = content.replace(/\n{3,}/g, "\n\n");
46
+ writeFileSync(filePath, content, "utf-8");
47
+ });
48
+ },
49
+ };
50
+ };
51
+
52
+ export default {
53
+ input: "src/index.js",
54
+ output: {
55
+ file: "dist/ruby-wasm-ui.js",
56
+ format: "esm",
57
+ sourcemap: true,
58
+ },
59
+ plugins: [
60
+ replace({
61
+ preventAssignment: true,
62
+ values: {
63
+ "window.RUBY_WASM_UI_ENV": JSON.stringify(
64
+ isDevelopment ? "development" : "production"
65
+ ),
66
+ "window.RUBY_WASM_UI_FILES": JSON.stringify(rubyFiles),
67
+ },
68
+ }),
69
+ copy({
70
+ targets: [
71
+ {
72
+ src: "src/ruby_wasm_ui/**/*",
73
+ dest: "dist/ruby_wasm_ui",
74
+ flatten: false,
75
+ },
76
+ {
77
+ src: "src/ruby_wasm_ui.rb",
78
+ dest: "dist",
79
+ },
80
+ ],
81
+ }),
82
+ cleanup({
83
+ comments: "none",
84
+ extensions: ["js"],
85
+ }),
86
+ removeRequireRelative(),
87
+ filesize(),
88
+ ],
89
+ };
@@ -0,0 +1,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyWasmUi do
6
+ describe '.define_component' do
7
+ let(:template) { ->(component) { 'rendered content' } }
8
+ let(:state) { ->(props) { { count: 0 } } }
9
+
10
+ context 'with template proc' do
11
+ it 'works with template proc that accepts component argument' do
12
+ component_class = RubyWasmUi.define_component(
13
+ template: ->(component) { 'rendered with component' }
14
+ )
15
+ instance = component_class.new
16
+ expect(instance.template).to eq('rendered with component')
17
+ end
18
+
19
+ it 'works with template proc that accepts no arguments' do
20
+ component_class = RubyWasmUi.define_component(
21
+ template: -> { 'rendered without args' }
22
+ )
23
+ instance = component_class.new
24
+ expect(instance.template).to eq('rendered without args')
25
+ end
26
+
27
+ it 'can access component state and props in template proc without arguments' do
28
+ component_class = RubyWasmUi.define_component(
29
+ template: -> { "count: #{@state[:count]}, name: #{@props[:name]}" },
30
+ state: -> { { count: 5 } }
31
+ )
32
+ instance = component_class.new(name: 'test')
33
+ expect(instance.template).to eq('count: 5, name: test')
34
+ end
35
+
36
+ it 'can access component methods in template proc without arguments' do
37
+ component_class = RubyWasmUi.define_component(
38
+ template: -> { helper_method },
39
+ methods: {
40
+ helper_method: -> { 'helper result' }
41
+ }
42
+ )
43
+ instance = component_class.new
44
+ expect(instance.template).to eq('helper result')
45
+ end
46
+
47
+ it 'passes correct component instance when template proc has arguments' do
48
+ received_component = nil
49
+ component_class = RubyWasmUi.define_component(
50
+ template: ->(component) {
51
+ received_component = component
52
+ 'rendered'
53
+ }
54
+ )
55
+ instance = component_class.new
56
+ instance.template
57
+ expect(received_component).to eq(instance)
58
+ end
59
+
60
+ it 'handles template proc with variable arity correctly' do
61
+ # Test with proc that can accept 0 or more arguments
62
+ component_class = RubyWasmUi.define_component(
63
+ template: ->(*args) { "args count: #{args.length}" }
64
+ )
65
+ instance = component_class.new
66
+ # Variable arity procs (arity < 0) should be called with component argument
67
+ expect(instance.template).to eq('args count: 1')
68
+ end
69
+
70
+ it 'works with Vdom.h in template proc without arguments' do
71
+ component_class = RubyWasmUi.define_component(
72
+ template: -> {
73
+ RubyWasmUi::Vdom.h('div', {}, ["Hello #{@props[:name]}"])
74
+ },
75
+ state: -> { { count: 0 } }
76
+ )
77
+ instance = component_class.new(name: 'World')
78
+ result = instance.template
79
+ expect(result).to be_a(RubyWasmUi::Vdom)
80
+ expect(result.tag).to eq('div')
81
+ expect(result.children.length).to eq(1)
82
+ expect(result.children.first).to be_a(RubyWasmUi::Vdom)
83
+ expect(result.children.first.type).to eq('text')
84
+ expect(result.children.first.value).to eq('Hello World')
85
+ end
86
+
87
+ it 'works with Vdom.h in template proc with component argument' do
88
+ component_class = RubyWasmUi.define_component(
89
+ template: ->(component) {
90
+ RubyWasmUi::Vdom.h('div', {}, ["Count: #{component.state[:count]}"])
91
+ },
92
+ state: -> { { count: 42 } }
93
+ )
94
+ instance = component_class.new
95
+ result = instance.template
96
+ expect(result).to be_a(RubyWasmUi::Vdom)
97
+ expect(result.tag).to eq('div')
98
+ expect(result.children.length).to eq(1)
99
+ expect(result.children.first).to be_a(RubyWasmUi::Vdom)
100
+ expect(result.children.first.type).to eq('text')
101
+ expect(result.children.first.value).to eq('Count: 42')
102
+ end
103
+ end
104
+
105
+ context 'with state proc' do
106
+ it 'works with state proc that accepts props argument' do
107
+ component_class = RubyWasmUi.define_component(
108
+ template: -> { 'content' },
109
+ state: ->(props) { { value: props[:initial] } }
110
+ )
111
+ instance = component_class.new(initial: 5)
112
+ expect(instance.state).to eq({ value: 5 })
113
+ end
114
+
115
+ it 'works with state proc that accepts no arguments' do
116
+ component_class = RubyWasmUi.define_component(
117
+ template: -> { 'content' },
118
+ state: -> { { value: 10 } }
119
+ )
120
+ instance = component_class.new
121
+ expect(instance.state).to eq({ value: 10 })
122
+ end
123
+ end
124
+
125
+ context 'with methods parameter' do
126
+ it 'successfully adds custom methods to the component' do
127
+ custom_methods = {
128
+ increment: -> { @state[:count] += 1 },
129
+ get_double_count: -> { @state[:count] * 2 }
130
+ }
131
+
132
+ component_class = RubyWasmUi.define_component(
133
+ template:,
134
+ state:,
135
+ methods: custom_methods
136
+ )
137
+
138
+ instance = component_class.new
139
+
140
+ expect(instance).to respond_to(:increment)
141
+ expect(instance).to respond_to(:get_double_count)
142
+
143
+ # Test the custom methods work correctly
144
+ expect(instance.get_double_count).to eq(0)
145
+ instance.increment
146
+ expect(instance.state[:count]).to eq(1)
147
+ expect(instance.get_double_count).to eq(2)
148
+ end
149
+
150
+ it 'raises error when method name conflicts with existing method' do
151
+ custom_methods = {
152
+ template: -> { 'custom template' } # conflicts with existing template method
153
+ }
154
+
155
+ expect {
156
+ RubyWasmUi.define_component(
157
+ template:,
158
+ methods: custom_methods
159
+ )
160
+ }.to raise_error(/Method "template\(\)" already exists in the component\./)
161
+ end
162
+
163
+ it 'raises error when method name conflicts with private method' do
164
+ custom_methods = {
165
+ patch: -> { 'custom patch' } # conflicts with existing private method
166
+ }
167
+
168
+ expect {
169
+ RubyWasmUi.define_component(
170
+ template:,
171
+ methods: custom_methods
172
+ )
173
+ }.to raise_error(/Method "patch\(\)" already exists in the component\./)
174
+ end
175
+
176
+ it 'works correctly with empty methods hash' do
177
+ component_class = RubyWasmUi.define_component(
178
+ template:,
179
+ methods: {}
180
+ )
181
+
182
+ instance = component_class.new
183
+ expect(instance).to be_a(RubyWasmUi::Component)
184
+ end
185
+
186
+ it 'allows method names as strings' do
187
+ custom_methods = {
188
+ 'string_method' => -> { 'method called' }
189
+ }
190
+
191
+ component_class = RubyWasmUi.define_component(
192
+ template:,
193
+ methods: custom_methods
194
+ )
195
+
196
+ instance = component_class.new
197
+ expect(instance).to respond_to('string_method')
198
+ expect(instance.string_method).to eq('method called')
199
+ end
200
+ end
201
+
202
+ context 'without methods parameter' do
203
+ it 'creates component successfully when methods is not provided' do
204
+ component_class = RubyWasmUi.define_component(template:)
205
+
206
+ instance = component_class.new
207
+ expect(instance).to be_a(RubyWasmUi::Component)
208
+ end
209
+ end
210
+
211
+ context 'with on_mounted parameter' do
212
+ it 'works with on_mounted proc that accepts component argument' do
213
+ mounted_called = false
214
+ received_component = nil
215
+
216
+ component_class = RubyWasmUi.define_component(
217
+ template: -> { 'content' },
218
+ on_mounted: ->(component) {
219
+ mounted_called = true
220
+ received_component = component
221
+ }
222
+ )
223
+
224
+ instance = component_class.new
225
+ instance.on_mounted
226
+
227
+ expect(mounted_called).to be true
228
+ expect(received_component).to eq(instance)
229
+ end
230
+
231
+ it 'works with on_mounted proc that accepts no arguments' do
232
+ mounted_called = false
233
+
234
+ component_class = RubyWasmUi.define_component(
235
+ template: -> { 'content' },
236
+ on_mounted: -> {
237
+ mounted_called = true
238
+ }
239
+ )
240
+
241
+ instance = component_class.new
242
+ instance.on_mounted
243
+
244
+ expect(mounted_called).to be true
245
+ end
246
+
247
+ it 'allows calling component methods directly in on_mounted without arguments' do
248
+ method_called_with_self = nil
249
+
250
+ component_class = RubyWasmUi.define_component(
251
+ template: -> { 'content' },
252
+ on_mounted: -> {
253
+ method_called_with_self = self
254
+ }
255
+ )
256
+
257
+ instance = component_class.new
258
+ instance.on_mounted
259
+
260
+ expect(method_called_with_self).to eq(instance)
261
+ end
262
+
263
+ it 'uses default empty proc when on_mounted is not provided' do
264
+ component_class = RubyWasmUi.define_component(template: -> { 'content' })
265
+ instance = component_class.new
266
+
267
+ expect { instance.on_mounted }.not_to raise_error
268
+ end
269
+ end
270
+
271
+ context 'with on_unmounted parameter' do
272
+ it 'works with on_unmounted proc that accepts component argument' do
273
+ unmounted_called = false
274
+ received_component = nil
275
+
276
+ component_class = RubyWasmUi.define_component(
277
+ template: -> { 'content' },
278
+ on_unmounted: ->(component) {
279
+ unmounted_called = true
280
+ received_component = component
281
+ }
282
+ )
283
+
284
+ instance = component_class.new
285
+ instance.on_unmounted
286
+
287
+ expect(unmounted_called).to be true
288
+ expect(received_component).to eq(instance)
289
+ end
290
+
291
+ it 'works with on_unmounted proc that accepts no arguments' do
292
+ unmounted_called = false
293
+
294
+ component_class = RubyWasmUi.define_component(
295
+ template: -> { 'content' },
296
+ on_unmounted: -> {
297
+ unmounted_called = true
298
+ }
299
+ )
300
+
301
+ instance = component_class.new
302
+ instance.on_unmounted
303
+
304
+ expect(unmounted_called).to be true
305
+ end
306
+
307
+ it 'allows calling component methods directly in on_unmounted without arguments' do
308
+ method_called_with_self = nil
309
+
310
+ component_class = RubyWasmUi.define_component(
311
+ template: -> { 'content' },
312
+ on_unmounted: -> {
313
+ method_called_with_self = self
314
+ }
315
+ )
316
+
317
+ instance = component_class.new
318
+ instance.on_unmounted
319
+
320
+ expect(method_called_with_self).to eq(instance)
321
+ end
322
+
323
+ it 'uses default empty proc when on_unmounted is not provided' do
324
+ component_class = RubyWasmUi.define_component(template: -> { 'content' })
325
+ instance = component_class.new
326
+
327
+ expect { instance.on_unmounted }.not_to raise_error
328
+ end
329
+ end
330
+ end
331
+
332
+ describe RubyWasmUi::Component do
333
+ describe '#emit' do
334
+ let(:template) { -> { 'content' } }
335
+ let(:component_class) { RubyWasmUi.define_component(template:) }
336
+ let(:event_name) { 'test_event' }
337
+
338
+ context 'when dispatcher is set' do
339
+ it 'dispatches event with payload' do
340
+ component = component_class.new
341
+ dispatcher = component.instance_variable_get(:@dispatcher)
342
+ payload = { value: 42 }
343
+
344
+ expect(dispatcher).to receive(:dispatch).with(event_name, payload)
345
+ component.emit(event_name, payload)
346
+ end
347
+
348
+ it 'dispatches event without payload (nil by default)' do
349
+ component = component_class.new
350
+ dispatcher = component.instance_variable_get(:@dispatcher)
351
+
352
+ expect(dispatcher).to receive(:dispatch).with(event_name, nil)
353
+ component.emit(event_name)
354
+ end
355
+ end
356
+
357
+ context 'when dispatcher is not set' do
358
+ it 'does not raise error' do
359
+ component = component_class.new
360
+ component.instance_variable_set(:@dispatcher, nil)
361
+
362
+ expect { component.emit(event_name) }.not_to raise_error
363
+ expect { component.emit(event_name, { value: 42 }) }.not_to raise_error
364
+ end
365
+ end
366
+ end
367
+
368
+ describe '#wire_event_handler' do
369
+ let(:template) { -> { 'content' } }
370
+ let(:component_class) { RubyWasmUi.define_component(template:) }
371
+ let(:event_name) { 'test_event' }
372
+ let(:parent_component) { component_class.new }
373
+
374
+ context 'with parent component' do
375
+ it 'handles event with payload when handler has arity of 1' do
376
+ handler = ->(payload) { payload[:value] * 2 }
377
+ component = component_class.new({}, { event_name => handler }, parent_component)
378
+ subscription = component.send(:wire_event_handler, event_name, handler)
379
+ expect(subscription).to be_a(Proc)
380
+ end
381
+
382
+ it 'handles event without payload when handler has arity of 0' do
383
+ handler = -> { 'no payload' }
384
+ component = component_class.new({}, { event_name => handler }, parent_component)
385
+ subscription = component.send(:wire_event_handler, event_name, handler)
386
+ expect(subscription).to be_a(Proc)
387
+ end
388
+ end
389
+
390
+ context 'without parent component' do
391
+ it 'handles event with payload when handler has arity of 1' do
392
+ handler = ->(payload) { payload[:value] * 2 }
393
+ component = component_class.new({}, { event_name => handler })
394
+ subscription = component.send(:wire_event_handler, event_name, handler)
395
+ expect(subscription).to be_a(Proc)
396
+ end
397
+
398
+ it 'handles event without payload when handler has arity of 0' do
399
+ handler = -> { 'no payload' }
400
+ component = component_class.new({}, { event_name => handler })
401
+ subscription = component.send(:wire_event_handler, event_name, handler)
402
+ expect(subscription).to be_a(Proc)
403
+ end
404
+ end
405
+
406
+ it 'returns a no-op unsubscription when subscription is nil' do
407
+ allow_any_instance_of(RubyWasmUi::Dispatcher).to receive(:subscribe).and_return(nil)
408
+ handler = -> { 'test' }
409
+ component = component_class.new({}, { event_name => handler })
410
+ subscription = component.send(:wire_event_handler, event_name, handler)
411
+ expect(subscription).to be_a(Proc)
412
+ expect { subscription.call }.not_to raise_error
413
+ end
414
+ end
415
+ end
416
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyWasmUi::Dom::Scheduler do
6
+ let(:mock_job) { -> { 'job executed' } }
7
+ let(:js) { class_double('JS').as_stubbed_const }
8
+ let(:global) { double('JS.global') }
9
+
10
+ before do
11
+ allow(js).to receive(:global).and_return(global)
12
+ allow(global).to receive(:queueMicrotask)
13
+ described_class.initialize_scheduler
14
+ end
15
+
16
+ describe '.initialize_scheduler' do
17
+ it 'initializes the scheduler with empty jobs and scheduled false' do
18
+ expect(described_class.jobs).to eq([])
19
+ expect(described_class.scheduled).to be false
20
+ end
21
+ end
22
+
23
+ describe '.enqueue_job' do
24
+ context 'when jobs is nil' do
25
+ before do
26
+ described_class.jobs = nil
27
+ end
28
+
29
+ it 'initializes scheduler and enqueues the job' do
30
+ described_class.enqueue_job(mock_job)
31
+ expect(described_class.jobs).to eq([mock_job])
32
+ end
33
+ end
34
+
35
+ context 'when jobs is already initialized' do
36
+ it 'enqueues the job' do
37
+ described_class.enqueue_job(mock_job)
38
+ expect(described_class.jobs).to eq([mock_job])
39
+ end
40
+ end
41
+
42
+ it 'schedules an update' do
43
+ expect(global).to receive(:queueMicrotask)
44
+ described_class.enqueue_job(mock_job)
45
+ end
46
+ end
47
+
48
+ describe '.schedule_update' do
49
+ context 'when not already scheduled' do
50
+ before do
51
+ described_class.scheduled = false
52
+ end
53
+
54
+ it 'sets scheduled to true and queues microtask' do
55
+ expect(global).to receive(:queueMicrotask)
56
+ described_class.send(:schedule_update)
57
+ expect(described_class.scheduled).to be true
58
+ end
59
+ end
60
+
61
+ context 'when already scheduled' do
62
+ before do
63
+ described_class.scheduled = true
64
+ end
65
+
66
+ it 'does not queue microtask' do
67
+ expect(global).not_to receive(:queueMicrotask)
68
+ described_class.send(:schedule_update)
69
+ end
70
+ end
71
+ end
72
+
73
+ describe '.process_jobs' do
74
+ let(:job1) { spy('job1') }
75
+ let(:job2) { spy('job2') }
76
+
77
+ before do
78
+ described_class.jobs = [job1, job2]
79
+ described_class.scheduled = true
80
+ end
81
+
82
+ it 'processes all jobs in the queue' do
83
+ described_class.send(:process_jobs)
84
+ expect(job1).to have_received(:call)
85
+ expect(job2).to have_received(:call)
86
+ end
87
+
88
+ it 'empties the jobs queue' do
89
+ described_class.send(:process_jobs)
90
+ expect(described_class.jobs).to be_empty
91
+ end
92
+
93
+ it 'sets scheduled to false after processing' do
94
+ described_class.send(:process_jobs)
95
+ expect(described_class.scheduled).to be false
96
+ end
97
+ end
98
+ end