funicular 0.2.1 → 0.4.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -0
  3. data/README.md +2 -2
  4. data/Rakefile +23 -15
  5. data/demo/test_chartjs.html +8 -8
  6. data/demo/test_component.html +8 -8
  7. data/demo/test_error_boundary.html +8 -5
  8. data/demo/test_router.html +11 -11
  9. data/demo/tic-tac-toe.html +4 -4
  10. data/docs/architecture.md +80 -30
  11. data/lib/funicular/compiler.rb +13 -13
  12. data/lib/funicular/helpers/picoruby_helper.rb +24 -1
  13. data/lib/funicular/ssr/runtime.rb +3 -0
  14. data/lib/funicular/ssr.rb +2 -1
  15. data/lib/funicular/testing/node_runner.rb +1 -1
  16. data/lib/funicular/vendor/mrbc/VERSION +1 -0
  17. data/lib/funicular/vendor/{picorbc/picorbc.js → mrbc/mrbc.js} +626 -486
  18. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  19. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  20. data/lib/funicular/vendor/picoruby/debug/init.iife.js +19 -4
  21. data/lib/funicular/vendor/picoruby/debug/picoruby.js +675 -449
  22. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  23. data/lib/funicular/vendor/picoruby/dist/init.iife.js +19 -4
  24. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  25. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +860 -567
  28. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  29. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -1
  30. data/lib/funicular/version.rb +1 -1
  31. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +25 -25
  32. data/lib/tasks/funicular.rake +1 -1
  33. data/minitest/commands_routes_test.rb +97 -0
  34. data/minitest/compiler_test.rb +195 -0
  35. data/minitest/configuration_test.rb +64 -0
  36. data/minitest/dsl_test.rb +237 -0
  37. data/minitest/fixtures/funicular_app/components/greeting_component.rb +2 -2
  38. data/minitest/funicular_test.rb +28 -2
  39. data/minitest/middleware_test.rb +154 -0
  40. data/minitest/picoruby_helper_test.rb +139 -0
  41. data/minitest/route_parser_test.rb +139 -0
  42. data/minitest/schema_test.rb +23 -0
  43. data/minitest/sig_tags_test.rb +30 -0
  44. data/minitest/ssr_test.rb +57 -0
  45. data/minitest/support/rails_stub.rb +59 -0
  46. data/minitest/test_helper.rb +20 -0
  47. data/minitest/testing_test.rb +267 -0
  48. data/minitest/view_context_test.rb +101 -0
  49. data/mrblib/0_tags.rb +62 -0
  50. data/mrblib/component.rb +255 -244
  51. data/mrblib/debug.rb +7 -6
  52. data/mrblib/differ.rb +3 -1
  53. data/mrblib/error_boundary.rb +11 -13
  54. data/mrblib/form_builder.rb +28 -24
  55. data/mrblib/funicular.rb +2 -1
  56. data/mrblib/html_serializer.rb +14 -13
  57. data/mrblib/http.rb +12 -1
  58. data/mrblib/patcher.rb +12 -9
  59. data/mrblib/router.rb +9 -5
  60. data/mrblib/runtime.rb +28 -0
  61. data/mrblib/styles.rb +107 -21
  62. data/mrblib/vdom.rb +90 -9
  63. data/mrblib/view_context.rb +106 -0
  64. data/sig/component.rbs +47 -84
  65. data/sig/form_builder.rbs +2 -1
  66. data/sig/html_serializer.rbs +2 -1
  67. data/sig/http.rbs +1 -0
  68. data/sig/patcher.rbs +1 -1
  69. data/sig/router.rbs +2 -13
  70. data/sig/runtime.rbs +13 -0
  71. data/sig/styles.rbs +19 -7
  72. data/sig/tags.rbs +54 -0
  73. data/sig/vdom.rbs +11 -2
  74. data/sig/view_context.rbs +60 -0
  75. metadata +22 -5
  76. data/lib/funicular/vendor/picorbc/VERSION +0 -1
  77. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Funicular
4
- VERSION = "0.2.1"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -55,7 +55,7 @@ class FunicularChatComponent < Funicular::Component
55
55
  def subscribe
56
56
  @consumer = Funicular::Cable.create_consumer("/cable")
57
57
  @subscription = @consumer.subscriptions.create(channel: "FunicularChatChannel") do |message|
58
- patch(messages: state.messages + [message])
58
+ patch(messages: state[:messages] + [message])
59
59
  end
60
60
  @subscription.on_connected do
61
61
  patch(connected: true)
@@ -72,10 +72,10 @@ class FunicularChatComponent < Funicular::Component
72
72
 
73
73
  def send_message(event = nil)
74
74
  event.preventDefault if event
75
- return if state.sending
75
+ return if state[:sending]
76
76
 
77
- name = state.name.to_s.strip
78
- body = state.body.to_s.strip
77
+ name = state[:name].to_s.strip
78
+ body = state[:body].to_s.strip
79
79
  if name == "" || body == ""
80
80
  patch(error: "Name and message are required")
81
81
  return
@@ -95,37 +95,37 @@ class FunicularChatComponent < Funicular::Component
95
95
  end
96
96
 
97
97
  def render
98
- div(class: s.shell) do
99
- div(class: s.panel) do
100
- div(class: s.header) do
101
- h1(class: s.title) { "Funicular Chat" }
102
- p(class: s.subtitle) do
103
- state.connected ? "Realtime Ruby chat over ActionCable" : "Connecting..."
98
+ div(class: styles.shell) do
99
+ div(class: styles.panel) do
100
+ div(class: styles.header) do
101
+ h1(class: styles.title) { "Funicular Chat" }
102
+ p(class: styles.subtitle) do
103
+ state[:connected] ? "Realtime Ruby chat over ActionCable" : "Connecting..."
104
104
  end
105
105
  end
106
106
 
107
- div(class: s.messages) do
108
- if state.messages.empty?
109
- p(class: s.empty) { "No messages yet." }
107
+ div(class: styles.messages) do
108
+ if state[:messages].empty?
109
+ p(class: styles.empty) { "No messages yet." }
110
110
  else
111
- state.messages.each do |message|
112
- div(class: s.message, key: message["id"]) do
113
- div(class: s.name) { message["name"] }
114
- div(class: s.body) { message["body"] }
111
+ state[:messages].each do |message|
112
+ div(class: styles.message, key: message["id"]) do
113
+ div(class: styles.name) { message["name"] }
114
+ div(class: styles.body) { message["body"] }
115
115
  end
116
116
  end
117
117
  end
118
118
  end
119
119
 
120
- form(onsubmit: :send_message, class: s.form) do
121
- if state.error
122
- div(class: s.error) { state.error }
120
+ form(onsubmit: :send_message, class: styles.form) do
121
+ if state[:error]
122
+ div(class: styles.error) { state[:error] }
123
123
  end
124
- div(class: s.row) do
125
- input(type: "text", value: state.name, placeholder: "Name", oninput: :update_name, class: s.input)
126
- input(type: "text", value: state.body, placeholder: "Message", oninput: :update_body, class: s.input)
127
- button(type: "submit", class: s.button(state.sending ? :disabled : :enabled), disabled: state.sending) do
128
- state.sending ? "Sending..." : "Send"
124
+ div(class: styles.row) do
125
+ input(type: "text", value: state[:name], placeholder: "Name", oninput: :update_name, class: styles.input)
126
+ input(type: "text", value: state[:body], placeholder: "Message", oninput: :update_body, class: styles.input)
127
+ button(type: "submit", class: styles.button(state[:sending] ? :disabled : :enabled), disabled: state[:sending]) do
128
+ state[:sending] ? "Sending..." : "Send"
129
129
  end
130
130
  end
131
131
  end
@@ -29,7 +29,7 @@ namespace :funicular do
29
29
  rescue Funicular::Plugin::Error => e
30
30
  puts "ERROR: #{e.message}"
31
31
  exit 1
32
- rescue Funicular::Compiler::PicorbcMissingError => e
32
+ rescue Funicular::Compiler::MrbcMissingError => e
33
33
  puts "ERROR: #{e.message}"
34
34
  exit 1
35
35
  rescue => e
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ require "test_helper"
7
+ require_relative "support/rails_stub"
8
+ require "funicular/commands/routes"
9
+
10
+ # Exercises the `funicular:routes` CLI command: its guard clauses (not a Rails
11
+ # app / no initializer / no routes) and the aligned routes table it prints.
12
+ class CommandsRoutesTest < Minitest::Test
13
+ def setup
14
+ Rails.reset_stub!
15
+ end
16
+
17
+ def teardown
18
+ Rails.reset_stub!
19
+ end
20
+
21
+ # Runs Routes#execute inside a throwaway app dir, capturing stdout and the
22
+ # SystemExit status (nil when the command runs to completion).
23
+ def run_in_app(files: {})
24
+ Dir.mktmpdir do |dir|
25
+ files.each do |rel, contents|
26
+ path = File.join(dir, rel)
27
+ FileUtils.mkdir_p(File.dirname(path))
28
+ File.write(path, contents)
29
+ end
30
+ Rails.root = Pathname(dir)
31
+
32
+ status = nil
33
+ out = +""
34
+ Dir.chdir(dir) do
35
+ out, = capture_io do
36
+ begin
37
+ Funicular::Commands::Routes.new.execute
38
+ rescue SystemExit => e
39
+ status = e.status
40
+ end
41
+ end
42
+ end
43
+ yield out, status
44
+ end
45
+ end
46
+
47
+ def test_errors_when_not_in_a_rails_app
48
+ run_in_app(files: {}) do |out, status|
49
+ assert_equal 1, status
50
+ assert_includes out, "Not in a Rails application"
51
+ end
52
+ end
53
+
54
+ def test_reports_when_initializer_is_missing
55
+ run_in_app(files: { "config/application.rb" => "# app\n" }) do |out, status|
56
+ assert_equal 0, status
57
+ assert_includes out, "No Funicular routes found"
58
+ end
59
+ end
60
+
61
+ def test_reports_when_initializer_defines_no_routes
62
+ run_in_app(files: {
63
+ "config/application.rb" => "# app\n",
64
+ "app/funicular/initializer.rb" => "# nothing here\n"
65
+ }) do |out, status|
66
+ assert_equal 0, status
67
+ assert_includes out, "No routes defined"
68
+ end
69
+ end
70
+
71
+ def test_prints_aligned_table_with_totals
72
+ initializer = <<~RUBY
73
+ router.get("/", to: HomeComponent, as: "home")
74
+ router.get("/channels/:id", to: ChannelComponent)
75
+ RUBY
76
+
77
+ run_in_app(files: {
78
+ "config/application.rb" => "# app\n",
79
+ "app/funicular/initializer.rb" => initializer
80
+ }) do |out, status|
81
+ assert_nil status # ran to completion, no exit
82
+ assert_includes out, "Method"
83
+ assert_includes out, "Component"
84
+ assert_includes out, "HomeComponent"
85
+ assert_includes out, "home_path"
86
+ assert_includes out, "/channels/:id"
87
+ assert_includes out, "Total: 2 routes"
88
+ end
89
+ end
90
+
91
+ def test_total_is_singular_for_one_route
92
+ routes = [{ method: "GET", path: "/", component: "HomeComponent", helper: nil }]
93
+ out, = capture_io { Funicular::Commands::Routes.new.send(:print_routes_table, routes) }
94
+ assert_includes out, "Total: 1 route"
95
+ refute_includes out, "1 routes"
96
+ end
97
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ require "test_helper"
7
+ require_relative "support/rails_stub"
8
+
9
+ # Exercises the environment-independent parts of Funicular::Compiler: the
10
+ # source-file discovery/ordering (shared with the SSR runtime), the `node`
11
+ # executable lookup, and the vendored-mrbc availability guard. The actual
12
+ # spawn of mrbc via Node is integration-only and not covered here.
13
+ class CompilerTest < Minitest::Test
14
+ def make_compiler(source_dir: "/tmp/app", output_file: "/tmp/out.mrb", **opts)
15
+ Funicular::Compiler.new(source_dir: source_dir, output_file: output_file, **opts)
16
+ end
17
+
18
+ # --- source_files ordering -------------------------------------------
19
+
20
+ def test_source_files_orders_models_stores_components_then_initializers
21
+ Dir.mktmpdir do |dir|
22
+ %w[models stores components].each { |d| FileUtils.mkdir_p(File.join(dir, d)) }
23
+ File.write(File.join(dir, "models", "b_model.rb"), "")
24
+ File.write(File.join(dir, "models", "a_model.rb"), "")
25
+ File.write(File.join(dir, "stores", "s_store.rb"), "")
26
+ File.write(File.join(dir, "components", "c_component.rb"), "")
27
+ File.write(File.join(dir, "app_initializer.rb"), "")
28
+ File.write(File.join(dir, "initializer.rb"), "")
29
+
30
+ files = Funicular::Compiler.source_files(dir).map { |f| f.sub("#{dir}/", "") }
31
+
32
+ assert_equal(
33
+ [
34
+ "models/a_model.rb",
35
+ "models/b_model.rb",
36
+ "stores/s_store.rb",
37
+ "components/c_component.rb",
38
+ "app_initializer.rb",
39
+ "initializer.rb"
40
+ ],
41
+ files
42
+ )
43
+ end
44
+ end
45
+
46
+ def test_source_files_recurses_into_subdirectories
47
+ Dir.mktmpdir do |dir|
48
+ FileUtils.mkdir_p(File.join(dir, "components", "nested"))
49
+ File.write(File.join(dir, "components", "nested", "deep_component.rb"), "")
50
+
51
+ files = Funicular::Compiler.source_files(dir)
52
+
53
+ assert_equal 1, files.size
54
+ assert_match %r{components/nested/deep_component\.rb\z}, files.first
55
+ end
56
+ end
57
+
58
+ def test_source_files_empty_for_missing_directory
59
+ assert_equal [], Funicular::Compiler.source_files("/no/such/dir")
60
+ end
61
+
62
+ # --- node executable lookup ------------------------------------------
63
+
64
+ def test_node_command_prefers_node_env_var
65
+ original = ENV["NODE"]
66
+ ENV["NODE"] = "/custom/bin/node"
67
+ assert_equal "/custom/bin/node", make_compiler.send(:node_command)
68
+ ensure
69
+ ENV["NODE"] = original
70
+ end
71
+
72
+ def test_which_finds_executable_on_path
73
+ Dir.mktmpdir do |dir|
74
+ exe = File.join(dir, "faketool")
75
+ File.write(exe, "#!/bin/sh\n")
76
+ File.chmod(0o755, exe)
77
+
78
+ original = ENV["PATH"]
79
+ ENV["PATH"] = dir
80
+ assert_equal exe, make_compiler.send(:which, "faketool")
81
+ assert_nil make_compiler.send(:which, "does_not_exist_tool")
82
+ ensure
83
+ ENV["PATH"] = original
84
+ end
85
+ end
86
+
87
+ # --- availability guard ----------------------------------------------
88
+
89
+ def test_compile_raises_when_vendored_mrbc_missing
90
+ skip "vendored mrbc present in this checkout" if File.exist?(Funicular::Compiler::MRBC_JS)
91
+
92
+ error = assert_raises(Funicular::Compiler::MrbcMissingError) { make_compiler.compile }
93
+ assert_includes error.message, "Vendored mrbc not found"
94
+ end
95
+
96
+ def test_prepend_source_files_are_coerced_to_strings
97
+ compiler = make_compiler(prepend_source_files: [Pathname.new("/a.rb"), :b])
98
+ assert_equal ["/a.rb", "b"], compiler.prepend_source_files
99
+ end
100
+
101
+ # --- source gathering (writes the FUNICULAR_ENV temp file) -----------
102
+
103
+ def test_gather_source_files_prepends_extras_and_writes_env_file
104
+ Rails.reset_stub!
105
+ Rails.env_name = "test"
106
+ Dir.mktmpdir do |dir|
107
+ FileUtils.mkdir_p(File.join(dir, "components"))
108
+ File.write(File.join(dir, "components", "a_component.rb"), "")
109
+ output = File.join(dir, "build", "app.mrb")
110
+
111
+ compiler = make_compiler(source_dir: dir, output_file: output,
112
+ prepend_source_files: ["/plugin/x.rb"])
113
+ compiler.send(:gather_source_files)
114
+
115
+ gathered = compiler.instance_variable_get(:@source_files)
116
+ assert_equal "/plugin/x.rb", gathered.first
117
+ assert(gathered.any? { |f| f.end_with?("a_component.rb") })
118
+
119
+ env_file = compiler.instance_variable_get(:@env_file)
120
+ assert_match(/FUNICULAR_ENV.*test/, File.read(env_file))
121
+ ensure
122
+ Rails.reset_stub!
123
+ end
124
+ end
125
+
126
+ def test_gather_source_files_raises_without_ruby_files
127
+ Dir.mktmpdir do |dir|
128
+ compiler = make_compiler(source_dir: dir, output_file: File.join(dir, "out.mrb"))
129
+ error = assert_raises(RuntimeError) { compiler.send(:gather_source_files) }
130
+ assert_includes error.message, "No Ruby files found"
131
+ end
132
+ end
133
+
134
+ # --- compile_to_mrb (spawns the mrbc command) ------------------------
135
+ #
136
+ # `true`/`false` stand in for `node mrbc.js ...`: both ignore their argv, so
137
+ # the exit status alone drives the success/failure branches without needing a
138
+ # real Node/WASM toolchain.
139
+
140
+ def prepared_compiler(dir, node:, debug_mode: false)
141
+ src = File.join(dir, "a.rb")
142
+ File.write(src, "")
143
+ compiler = make_compiler(source_dir: dir, output_file: File.join(dir, "out.mrb"),
144
+ debug_mode: debug_mode)
145
+ compiler.instance_variable_set(:@node_command, node)
146
+ compiler.instance_variable_set(:@source_files, [src])
147
+ compiler.instance_variable_set(:@env_file, nil)
148
+ compiler
149
+ end
150
+
151
+ def test_compile_to_mrb_succeeds_when_command_exits_zero
152
+ Dir.mktmpdir do |dir|
153
+ compiler = prepared_compiler(dir, node: "/bin/true", debug_mode: true)
154
+ out, = capture_io { compiler.send(:compile_to_mrb) }
155
+ assert_includes out, "Successfully compiled"
156
+ assert_includes out, "Debug mode: true"
157
+ end
158
+ end
159
+
160
+ def test_compile_to_mrb_raises_when_command_fails
161
+ Dir.mktmpdir do |dir|
162
+ compiler = prepared_compiler(dir, node: "/bin/false")
163
+ error = nil
164
+ capture_io do
165
+ error = assert_raises(RuntimeError) { compiler.send(:compile_to_mrb) }
166
+ end
167
+ assert_includes error.message, "Failed to compile with mrbc"
168
+ end
169
+ end
170
+
171
+ def test_compile_to_mrb_deletes_env_file_unless_kept
172
+ Dir.mktmpdir do |dir|
173
+ compiler = prepared_compiler(dir, node: "/bin/true")
174
+ env_file = File.join(dir, "out.mrb.env.rb")
175
+ File.write(env_file, "ENV['FUNICULAR_ENV'] = 'test'\n")
176
+ compiler.instance_variable_set(:@env_file, env_file)
177
+
178
+ capture_io { compiler.send(:compile_to_mrb) }
179
+ refute File.exist?(env_file), "temp env file should be removed"
180
+ end
181
+ end
182
+
183
+ def test_logger_is_used_when_provided
184
+ Dir.mktmpdir do |dir|
185
+ messages = []
186
+ fake_logger = Object.new
187
+ fake_logger.define_singleton_method(:info) { |m| messages << m }
188
+ compiler = prepared_compiler(dir, node: "/bin/true")
189
+ compiler.instance_variable_set(:@logger, fake_logger)
190
+
191
+ compiler.send(:compile_to_mrb)
192
+ assert(messages.any? { |m| m.include?("Successfully compiled") })
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Exercises Funicular::Configuration: the per-environment source selection, the
6
+ # source allowlist validation, and the cdn_version fallback to the vendored
7
+ # wasm version.
8
+ class ConfigurationTest < Minitest::Test
9
+ def setup
10
+ @config = Funicular::Configuration.new
11
+ end
12
+
13
+ def test_defaults
14
+ assert_equal :local_debug, @config.development_source
15
+ assert_equal :local_debug, @config.test_source
16
+ assert_equal :local_dist, @config.production_source
17
+ end
18
+
19
+ def test_source_for_known_environments
20
+ assert_equal :local_debug, @config.source_for("development")
21
+ assert_equal :local_debug, @config.source_for("test")
22
+ assert_equal :local_dist, @config.source_for("production")
23
+ end
24
+
25
+ def test_source_for_accepts_symbols
26
+ assert_equal :local_dist, @config.source_for(:production)
27
+ end
28
+
29
+ def test_source_for_unknown_environment_falls_back_to_development
30
+ assert_equal :local_debug, @config.source_for("staging")
31
+ end
32
+
33
+ def test_setters_accept_valid_sources
34
+ @config.development_source = :cdn
35
+ @config.test_source = "local_dist"
36
+ @config.production_source = :cdn
37
+
38
+ assert_equal :cdn, @config.development_source
39
+ assert_equal :local_dist, @config.test_source
40
+ assert_equal :cdn, @config.source_for("production")
41
+ end
42
+
43
+ def test_setters_reject_invalid_sources
44
+ error = assert_raises(ArgumentError) { @config.production_source = :nonsense }
45
+ assert_includes error.message, "Invalid Funicular source"
46
+ assert_includes error.message, "nonsense"
47
+ end
48
+
49
+ def test_cdn_version_prefers_explicit_value
50
+ @config.cdn_version = "9.9.9"
51
+ assert_equal "9.9.9", @config.cdn_version
52
+ end
53
+
54
+ def test_cdn_version_falls_back_to_vendored_wasm_version
55
+ # No explicit version set -> delegates to Funicular.vendored_wasm_version
56
+ # (nil in a source checkout without vendored artifacts).
57
+ vendored = Funicular.vendored_wasm_version
58
+ if vendored.nil?
59
+ assert_nil @config.cdn_version
60
+ else
61
+ assert_equal vendored, @config.cdn_version
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Bareword component DSL (0.4.0), CRuby/SSR side. Twin of test/dsl_test.rb;
6
+ # the attr_accessor case differs by VM: CRuby fires method_added for attr_*
7
+ # so the collision surfaces at class-definition time, while mruby relies on
8
+ # the mount-time sweep.
9
+ class DSLTest < Minitest::Test
10
+ def setup
11
+ Funicular::SSR::Runtime.load_framework!
12
+ end
13
+
14
+ def test_bareword_render_builds_nested_tree
15
+ klass = Class.new(Funicular::Component) do
16
+ def initialize_state
17
+ { items: ["a", "b"] }
18
+ end
19
+
20
+ def render
21
+ div(class: "list") do
22
+ h1 { "title" }
23
+ ul do
24
+ state[:items].each_with_index do |item, i|
25
+ li(key: i) { item }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ vnode = klass.new.build_vdom
33
+
34
+ assert_equal "div", vnode.tag
35
+ assert_equal ["h1", "ul"], vnode.children.map(&:tag)
36
+ assert_equal 2, vnode.children[1].children.size
37
+ end
38
+
39
+ def test_defining_reserved_tag_name_raises_at_class_definition
40
+ assert_raises(Funicular::DSLCollisionError) do
41
+ Class.new(Funicular::Component) do
42
+ def label
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ def test_attr_accessor_collision_raises_at_class_definition_on_cruby
49
+ # CRuby fires method_added for attr_*, so this is caught immediately
50
+ # (on mruby the mount-time sweep catches it instead).
51
+ assert_raises(Funicular::DSLCollisionError) do
52
+ Class.new(Funicular::Component) do
53
+ attr_accessor :label
54
+ end
55
+ end
56
+ end
57
+
58
+ def test_included_module_collision_is_caught_at_first_build
59
+ mod = Module.new do
60
+ def select
61
+ end
62
+ end
63
+ klass = Class.new(Funicular::Component) do
64
+ def render
65
+ div { "x" }
66
+ end
67
+ end
68
+ klass.include(mod)
69
+
70
+ assert_raises(Funicular::DSLCollisionError) { klass.new.build_vdom }
71
+ end
72
+
73
+ def test_allow_dsl_override_restores_user_method_and_tag_escape_hatch
74
+ klass = Class.new(Funicular::Component) do
75
+ allow_dsl_override :label
76
+
77
+ def label
78
+ "custom"
79
+ end
80
+
81
+ def render
82
+ div { tag(:label) { label } }
83
+ end
84
+ end
85
+
86
+ vnode = klass.new.build_vdom
87
+
88
+ assert_equal "label", vnode.children.first.tag
89
+ assert_equal ["custom"], vnode.children.first.children
90
+ end
91
+
92
+ def test_render_with_parameter_raises_migration_error
93
+ klass = Class.new(Funicular::Component) do
94
+ def render(h)
95
+ h
96
+ end
97
+ end
98
+
99
+ error = assert_raises(Funicular::DSLCollisionError) { klass.new.build_vdom }
100
+ assert_match(/must not take parameters/, error.message)
101
+ end
102
+
103
+ def test_p_with_non_hash_raises_with_hint
104
+ klass = Class.new(Funicular::Component) do
105
+ def render
106
+ div { p "debug me" }
107
+ end
108
+ end
109
+
110
+ error = assert_raises(ArgumentError) { klass.new.build_vdom }
111
+ assert_match(/puts x.inspect/, error.message)
112
+ end
113
+
114
+ def test_p_with_hash_builds_paragraph
115
+ klass = Class.new(Funicular::Component) do
116
+ def render
117
+ p(class: "para") { "text" }
118
+ end
119
+ end
120
+
121
+ vnode = klass.new.build_vdom
122
+
123
+ assert_equal "p", vnode.tag
124
+ assert_equal ["text"], vnode.children
125
+ end
126
+
127
+ def test_tag_outside_render_raises
128
+ klass = Class.new(Funicular::Component) do
129
+ def render
130
+ div { "x" }
131
+ end
132
+ end
133
+ component = klass.new
134
+ component.build_vdom
135
+
136
+ assert_raises(Funicular::RenderContextError) { component.div { "outside" } }
137
+ end
138
+
139
+ def test_bareword_styles_definition_and_method_access
140
+ klass = Class.new(Funicular::Component) do
141
+ styles do
142
+ shell "app-shell"
143
+ display "display-class"
144
+ button base: "btn", variants: { primary: "btn--primary" }, active: "btn--on"
145
+ end
146
+
147
+ def render
148
+ div(class: styles.shell) do
149
+ span(class: styles.display) { "d" }
150
+ button(class: styles.button(:primary)) { "b" }
151
+ a(class: styles[:button, true] | "extra") { "x" }
152
+ end
153
+ end
154
+ end
155
+
156
+ vnode = klass.new.build_vdom
157
+
158
+ assert_equal "app-shell", vnode.props[:class]
159
+ # :display would resolve to Object#display on an Object-based accessor;
160
+ # the BasicObject accessor keeps it a plain style name under CRuby SSR,
161
+ # matching the mruby client.
162
+ assert_equal "display-class", vnode.children[0].props[:class]
163
+ assert_equal "btn btn--primary", vnode.children[1].props[:class]
164
+ assert_equal "btn btn--on extra", vnode.children[2].props[:class]
165
+ end
166
+
167
+ def test_unknown_style_raises
168
+ klass = Class.new(Funicular::Component) do
169
+ styles do
170
+ shell "app-shell"
171
+ end
172
+
173
+ def render
174
+ div { "x" }
175
+ end
176
+ end
177
+ component = klass.new
178
+
179
+ assert_raises(NoMethodError) { component.styles.typo_name }
180
+ assert_raises(ArgumentError) { component.styles[:typo] }
181
+ end
182
+
183
+ def test_bareword_helper_call_inside_styles_block_raises
184
+ assert_raises(ArgumentError) do
185
+ Class.new(Funicular::Component) do
186
+ styles do
187
+ button base: helper_that_does_not_exist("x")
188
+ end
189
+ end
190
+ end
191
+ end
192
+
193
+ def test_duplicate_style_definition_raises
194
+ assert_raises(ArgumentError) do
195
+ Class.new(Funicular::Component) do
196
+ styles do
197
+ shell "a"
198
+ shell "b"
199
+ end
200
+ end
201
+ end
202
+ end
203
+
204
+ def test_suspense_fallback_runs_bareword_in_own_component
205
+ klass = Class.new(Funicular::Component) do
206
+ use_suspense :user, ->(resolve, _reject) { resolve.call("u") }
207
+
208
+ def render
209
+ div do
210
+ suspense(:user, fallback: -> { span { "loading" } }) do |res|
211
+ span { res[:user] }
212
+ end
213
+ end
214
+ end
215
+ end
216
+
217
+ component = klass.new
218
+ vnode = component.build_vdom
219
+ assert_equal "loading", vnode.children.first.children.first
220
+
221
+ component.instance_variable_get(:@suspense_data)[:user] = "u"
222
+ component.instance_variable_get(:@suspense_states)[:user] = :resolved
223
+ vnode = component.build_vdom
224
+ assert_equal "u", vnode.children.first.children.first
225
+ end
226
+
227
+ def test_error_boundary_fallback_receives_view_context
228
+ fallback = ->(h, error) { h.div(class: "custom-error") { error.message } }
229
+ boundary = Funicular::ErrorBoundary.new(fallback: fallback)
230
+ boundary.catch_error(RuntimeError.new("boom"))
231
+ vnode = boundary.build_vdom
232
+
233
+ assert_equal "div", vnode.tag
234
+ assert_equal "custom-error", vnode.props[:class]
235
+ assert_equal ["boom"], vnode.children
236
+ end
237
+ end