funicular 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +2 -2
  4. data/Rakefile +23 -15
  5. data/demo/test_chartjs.html +17 -17
  6. data/demo/test_component.html +15 -15
  7. data/demo/test_error_boundary.html +41 -41
  8. data/demo/test_router.html +57 -57
  9. data/demo/tic-tac-toe.html +29 -29
  10. data/docs/architecture.md +43 -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 +2 -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} +14 -1
  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 +16 -10
  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 +1 -1
  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 +85 -84
  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 +47 -46
  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/fixtures/funicular_app/components/greeting_component.rb +6 -6
  37. data/minitest/form_for_test.rb +2 -2
  38. data/minitest/funicular_test.rb +28 -2
  39. data/minitest/hydration_test.rb +2 -2
  40. data/minitest/middleware_test.rb +154 -0
  41. data/minitest/picoruby_helper_test.rb +139 -0
  42. data/minitest/route_parser_test.rb +139 -0
  43. data/minitest/schema_test.rb +23 -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/component.rb +158 -237
  50. data/mrblib/debug.rb +7 -6
  51. data/mrblib/differ.rb +3 -1
  52. data/mrblib/error_boundary.rb +19 -27
  53. data/mrblib/form_builder.rb +28 -24
  54. data/mrblib/funicular.rb +2 -1
  55. data/mrblib/html_serializer.rb +14 -13
  56. data/mrblib/http.rb +12 -1
  57. data/mrblib/patcher.rb +12 -9
  58. data/mrblib/router.rb +9 -5
  59. data/mrblib/runtime.rb +28 -0
  60. data/mrblib/styles.rb +13 -17
  61. data/mrblib/vdom.rb +90 -9
  62. data/mrblib/view_context.rb +135 -0
  63. data/sig/component.rbs +34 -85
  64. data/sig/error_boundary.rbs +4 -4
  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 +3 -4
  72. data/sig/vdom.rbs +11 -2
  73. data/sig/view_context.rbs +47 -0
  74. metadata +18 -5
  75. data/lib/funicular/vendor/picorbc/VERSION +0 -1
  76. 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.3.0"
5
5
  end
@@ -1,24 +1,25 @@
1
1
  class FunicularChatComponent < Funicular::Component
2
- styles do
3
- shell "funicular-chat"
4
- panel "funicular-chat__panel"
5
- header "funicular-chat__header"
6
- title "funicular-chat__title"
7
- subtitle "funicular-chat__subtitle"
8
- messages "funicular-chat__messages"
9
- empty "funicular-chat__empty"
10
- message "funicular-chat__message"
11
- name "funicular-chat__message-name"
12
- body "funicular-chat__message-body"
13
- form "funicular-chat__form"
14
- row "funicular-chat__form-row"
15
- input "funicular-chat__input"
16
- button base: "funicular-chat__button",
17
- variants: {
18
- enabled: "funicular-chat__button--enabled",
19
- disabled: "funicular-chat__button--disabled"
20
- }
21
- error "funicular-chat__error"
2
+ styles do |css|
3
+ css.define :shell, "funicular-chat"
4
+ css.define :panel, "funicular-chat__panel"
5
+ css.define :header, "funicular-chat__header"
6
+ css.define :title, "funicular-chat__title"
7
+ css.define :subtitle, "funicular-chat__subtitle"
8
+ css.define :messages, "funicular-chat__messages"
9
+ css.define :empty, "funicular-chat__empty"
10
+ css.define :message, "funicular-chat__message"
11
+ css.define :name, "funicular-chat__message-name"
12
+ css.define :body, "funicular-chat__message-body"
13
+ css.define :form, "funicular-chat__form"
14
+ css.define :row, "funicular-chat__form-row"
15
+ css.define :input, "funicular-chat__input"
16
+ css.define :button,
17
+ base: "funicular-chat__button",
18
+ variants: {
19
+ enabled: "funicular-chat__button--enabled",
20
+ disabled: "funicular-chat__button--disabled"
21
+ }
22
+ css.define :error, "funicular-chat__error"
22
23
  end
23
24
 
24
25
  def initialize_state
@@ -55,7 +56,7 @@ class FunicularChatComponent < Funicular::Component
55
56
  def subscribe
56
57
  @consumer = Funicular::Cable.create_consumer("/cable")
57
58
  @subscription = @consumer.subscriptions.create(channel: "FunicularChatChannel") do |message|
58
- patch(messages: state.messages + [message])
59
+ patch(messages: state[:messages] + [message])
59
60
  end
60
61
  @subscription.on_connected do
61
62
  patch(connected: true)
@@ -72,10 +73,10 @@ class FunicularChatComponent < Funicular::Component
72
73
 
73
74
  def send_message(event = nil)
74
75
  event.preventDefault if event
75
- return if state.sending
76
+ return if state[:sending]
76
77
 
77
- name = state.name.to_s.strip
78
- body = state.body.to_s.strip
78
+ name = state[:name].to_s.strip
79
+ body = state[:body].to_s.strip
79
80
  if name == "" || body == ""
80
81
  patch(error: "Name and message are required")
81
82
  return
@@ -94,38 +95,38 @@ class FunicularChatComponent < Funicular::Component
94
95
  end
95
96
  end
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
+ def render(h)
99
+ h.div(class: h.styles[:shell]) do |hh|
100
+ hh.div(class: h.styles[:panel]) do |panel|
101
+ panel.div(class: h.styles[:header]) do |header|
102
+ header.h1(class: h.styles[:title]) { "Funicular Chat" }
103
+ header.p(class: h.styles[:subtitle]) do
104
+ state[:connected] ? "Realtime Ruby chat over ActionCable" : "Connecting..."
104
105
  end
105
106
  end
106
107
 
107
- div(class: s.messages) do
108
- if state.messages.empty?
109
- p(class: s.empty) { "No messages yet." }
108
+ panel.div(class: h.styles[:messages]) do |messages|
109
+ if state[:messages].empty?
110
+ messages.p(class: h.styles[:empty]) { "No messages yet." }
110
111
  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"] }
112
+ state[:messages].each do |message|
113
+ messages.div(class: h.styles[:message], key: message["id"]) do |item|
114
+ item.div(class: h.styles[:name]) { message["name"] }
115
+ item.div(class: h.styles[:body]) { message["body"] }
115
116
  end
116
117
  end
117
118
  end
118
119
  end
119
120
 
120
- form(onsubmit: :send_message, class: s.form) do
121
- if state.error
122
- div(class: s.error) { state.error }
121
+ panel.form(onsubmit: :send_message, class: h.styles[:form]) do |form|
122
+ if state[:error]
123
+ form.div(class: h.styles[:error]) { state[:error] }
123
124
  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"
125
+ form.div(class: h.styles[:row]) do |row|
126
+ row.input(type: "text", value: state[:name], placeholder: "Name", oninput: :update_name, class: h.styles[:input])
127
+ row.input(type: "text", value: state[:body], placeholder: "Message", oninput: :update_body, class: h.styles[:input])
128
+ row.button(type: "submit", class: h.styles[:button, state[:sending] ? :disabled : :enabled], disabled: state[:sending]) do
129
+ state[:sending] ? "Sending..." : "Send"
129
130
  end
130
131
  end
131
132
  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
@@ -3,12 +3,12 @@ class GreetingComponent < Funicular::Component
3
3
  { title: "Default Title", items: [] }
4
4
  end
5
5
 
6
- def render
7
- div(class: "greeting") do
8
- h1 { state.title }
9
- ul do
10
- state.items.each do |item|
11
- li(key: item["id"]) { item["name"] }
6
+ def render(h)
7
+ h.div(class: "greeting") do |hh|
8
+ hh.h1 { state[:title] }
9
+ hh.ul do |hhh|
10
+ state[:items].each do |item|
11
+ hhh.li(key: item["id"]) { item["name"] }
12
12
  end
13
13
  end
14
14
  end
@@ -19,8 +19,8 @@ class FormForTest < Minitest::Test
19
19
  @submitted = data
20
20
  end
21
21
 
22
- def render
23
- form_for(:comment, on_submit: :handle_submit) do |f|
22
+ def render(h)
23
+ h.form_for(:comment, on_submit: :handle_submit) do |f|
24
24
  f.textarea(:body)
25
25
  f.submit("Post")
26
26
  end
@@ -2,12 +2,38 @@
2
2
 
3
3
  require "test_helper"
4
4
 
5
+ # Exercises the top-level Funicular module: version constant, the
6
+ # configuration accessor/DSL, and the vendored wasm version reader.
5
7
  class FunicularTest < Minitest::Test
6
8
  def test_VERSION
7
9
  assert ::Funicular.const_defined?(:VERSION)
10
+ assert_match(/\A\d+\.\d+\.\d+/, Funicular::VERSION)
8
11
  end
9
12
 
10
- def test_something_useful
11
- assert("expected"!="actual")
13
+ def test_configuration_is_memoized
14
+ assert_instance_of Funicular::Configuration, Funicular.configuration
15
+ assert_same Funicular.configuration, Funicular.configuration
16
+ end
17
+
18
+ def test_configure_yields_the_configuration
19
+ yielded = nil
20
+ Funicular.configure { |config| yielded = config }
21
+ assert_same Funicular.configuration, yielded
22
+ end
23
+
24
+ def test_vendored_wasm_version_reads_version_file
25
+ # The vendored artifacts are not present in a source checkout, so the
26
+ # reader swallows Errno::ENOENT and returns nil. When they are present it
27
+ # returns the trimmed VERSION file contents.
28
+ version_file = File.join(Funicular::VENDOR_PICORUBY_DIR, "VERSION")
29
+ Funicular.instance_variable_set(:@vendored_wasm_version, nil)
30
+
31
+ if File.exist?(version_file)
32
+ assert_equal File.read(version_file).strip, Funicular.vendored_wasm_version
33
+ else
34
+ assert_nil Funicular.vendored_wasm_version
35
+ end
36
+ ensure
37
+ Funicular.instance_variable_set(:@vendored_wasm_version, nil)
12
38
  end
13
39
  end
@@ -25,8 +25,8 @@ class HydrationMatchTest < Minitest::Test
25
25
  # invoked here (we feed vnodes directly); it only satisfies the abstract API.
26
26
  def probe
27
27
  klass = Class.new(Funicular::Component) do
28
- def render
29
- div { "x" }
28
+ def render(h)
29
+ h.div { "x" }
30
30
  end
31
31
 
32
32
  def match?(vnode, dom)