funicular 0.1.0 → 0.2.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +10 -2
  4. data/Rakefile +29 -0
  5. data/docs/architecture.md +113 -404
  6. data/lib/funicular/assets/funicular.css +23 -0
  7. data/lib/funicular/compiler.rb +23 -15
  8. data/lib/funicular/helpers/picoruby_helper.rb +65 -3
  9. data/lib/funicular/middleware.rb +34 -9
  10. data/lib/funicular/plugin.rb +147 -0
  11. data/lib/funicular/schema.rb +167 -0
  12. data/lib/funicular/ssr/runtime.rb +101 -0
  13. data/lib/funicular/ssr.rb +51 -0
  14. data/lib/funicular/testing/node_runner.mjs +293 -0
  15. data/lib/funicular/testing/node_runner.rb +190 -0
  16. data/lib/funicular/testing.rb +22 -0
  17. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  18. data/lib/funicular/vendor/picoruby/debug/picoruby.js +94 -75
  19. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  20. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  21. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  22. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  23. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  24. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  25. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  26. data/lib/funicular/version.rb +1 -1
  27. data/lib/funicular.rb +3 -0
  28. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  29. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  30. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  31. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  32. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  33. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  34. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  35. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  37. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  38. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  39. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  40. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  41. data/lib/tasks/funicular.rake +87 -4
  42. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  43. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  44. data/minitest/hydration_test.rb +87 -0
  45. data/minitest/plugin_test.rb +51 -0
  46. data/minitest/schema_test.rb +106 -0
  47. data/minitest/ssr_test.rb +94 -0
  48. data/minitest/validations_test.rb +183 -0
  49. data/mrbgem.rake +1 -0
  50. data/mrblib/0_validations.rb +206 -0
  51. data/mrblib/1_validators.rb +180 -0
  52. data/mrblib/cable.rb +24 -9
  53. data/mrblib/component.rb +172 -33
  54. data/mrblib/debug.rb +3 -0
  55. data/mrblib/differ.rb +47 -37
  56. data/mrblib/file_upload.rb +9 -1
  57. data/mrblib/form_builder.rb +21 -5
  58. data/mrblib/funicular.rb +97 -8
  59. data/mrblib/html_serializer.rb +121 -0
  60. data/mrblib/http.rb +123 -29
  61. data/mrblib/model.rb +50 -0
  62. data/mrblib/patcher.rb +74 -8
  63. data/mrblib/router.rb +40 -3
  64. data/mrblib/store.rb +304 -0
  65. data/mrblib/store_collection.rb +171 -0
  66. data/mrblib/store_singleton.rb +79 -0
  67. data/sig/cable.rbs +1 -0
  68. data/sig/component.rbs +13 -5
  69. data/sig/funicular.rbs +14 -1
  70. data/sig/html_serializer.rbs +20 -0
  71. data/sig/http.rbs +21 -6
  72. data/sig/model.rbs +6 -1
  73. data/sig/patcher.rbs +4 -1
  74. data/sig/router.rbs +3 -2
  75. data/sig/store.rbs +89 -0
  76. data/sig/store_collection.rbs +43 -0
  77. data/sig/store_singleton.rbs +19 -0
  78. data/sig/validations.rbs +103 -0
  79. data/sig/vdom.rbs +6 -6
  80. metadata +47 -12
  81. data/docs/README.md +0 -419
  82. data/docs/advanced-features.md +0 -632
  83. data/docs/components-and-state.md +0 -539
  84. data/docs/data-fetching.md +0 -528
  85. data/docs/forms.md +0 -446
  86. data/docs/rails-integration.md +0 -426
  87. data/docs/realtime.md +0 -543
  88. data/docs/routing-and-navigation.md +0 -427
  89. data/docs/styling.md +0 -285
@@ -0,0 +1,4 @@
1
+ class FunicularChatController < ApplicationController
2
+ def show
3
+ end
4
+ end
@@ -0,0 +1,13 @@
1
+ class FunicularChatMessage < ApplicationRecord
2
+ validates :name, presence: true, length: { maximum: 40 }
3
+ validates :body, presence: true, length: { maximum: 500 }
4
+
5
+ def as_json(*)
6
+ {
7
+ id: id,
8
+ name: name,
9
+ body: body,
10
+ created_at: created_at.iso8601
11
+ }
12
+ end
13
+ end
@@ -0,0 +1,23 @@
1
+ class FunicularChatMessagesController < ApplicationController
2
+ def index
3
+ messages = FunicularChatMessage.order(:created_at).last(50)
4
+ render json: messages
5
+ end
6
+
7
+ def create
8
+ message = FunicularChatMessage.new(message_params)
9
+
10
+ if message.save
11
+ ActionCable.server.broadcast("funicular_chat", message.as_json)
12
+ render json: message, status: :created
13
+ else
14
+ render json: { errors: message.errors.full_messages }, status: :unprocessable_entity
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def message_params
21
+ params.require(:message).permit(:name, :body)
22
+ end
23
+ end
@@ -0,0 +1,4 @@
1
+ Funicular.start(container: "app") do |router|
2
+ router.get("/funicular_chat", to: FunicularChatComponent, as: "funicular_chat")
3
+ router.set_default("/funicular_chat")
4
+ end
@@ -0,0 +1,6 @@
1
+ <%% content_for :head do %>
2
+ <%%= stylesheet_link_tag "funicular_chat", "data-turbo-track": "reload" %>
3
+ <%% end %>
4
+
5
+ <%%= funicular_app_container %>
6
+ <script type="application/x-mrb" src="<%%= asset_path("app.mrb") %>"></script>
@@ -4,6 +4,7 @@ namespace :funicular do
4
4
  desc "Compile Funicular Ruby files to .mrb format"
5
5
  task compile: :environment do
6
6
  require "funicular/compiler"
7
+ require "funicular/plugin"
7
8
 
8
9
  source_dir = Rails.root.join("app", "funicular")
9
10
  output_file = Rails.root.join("app", "assets", "builds", "app.mrb")
@@ -15,13 +16,20 @@ namespace :funicular do
15
16
  end
16
17
 
17
18
  begin
19
+ plugin_registry = Funicular::Plugin::Registry.new(Rails.root)
20
+ plugin_registry.validate!
21
+ plugin_registry.sync_assets
18
22
  compiler = Funicular::Compiler.new(
19
23
  source_dir: source_dir,
20
24
  output_file: output_file,
21
- debug_mode: debug_mode
25
+ debug_mode: debug_mode,
26
+ prepend_source_files: plugin_registry.local_source_files
22
27
  )
23
28
  compiler.compile
24
- rescue Funicular::Compiler::PicorbcNotFoundError => e
29
+ rescue Funicular::Plugin::Error => e
30
+ puts "ERROR: #{e.message}"
31
+ exit 1
32
+ rescue Funicular::Compiler::PicorbcMissingError => e
25
33
  puts "ERROR: #{e.message}"
26
34
  exit 1
27
35
  rescue => e
@@ -46,8 +54,8 @@ namespace :funicular do
46
54
  end
47
55
  end
48
56
 
49
- desc "Install Funicular debug assets and PicoRuby.wasm artifacts into a Rails app"
50
- task install: ["install:debug_assets", "install:wasm"] do
57
+ desc "Install Funicular debug assets, PicoRuby.wasm artifacts, and test support into a Rails app"
58
+ task install: ["install:debug_assets", "install:wasm", "install:test"] do
51
59
  puts ""
52
60
  puts "All Funicular assets installed."
53
61
  puts ""
@@ -64,6 +72,10 @@ namespace :funicular do
64
72
  puts ' <%= javascript_include_tag "funicular_debug", "data-turbo-track": "reload" %>'
65
73
  puts ' <%= stylesheet_link_tag "funicular_debug", "data-turbo-track": "reload" %>'
66
74
  puts ' <% end %>'
75
+ puts ""
76
+ puts " 4. Run `npm install` if package.json was created or updated."
77
+ puts " Client-side Funicular tests live under test/funicular/client/**/*_picotest.rb"
78
+ puts " and run through `bin/rails test`."
67
79
  end
68
80
 
69
81
  namespace :install do
@@ -126,6 +138,77 @@ namespace :funicular do
126
138
  puts "Installed PicoRuby #{variant} build to #{dst}"
127
139
  end
128
140
  end
141
+
142
+
143
+ desc "Install Funicular client test support"
144
+ task :test do
145
+ require "fileutils"
146
+ require "json"
147
+
148
+ test_dir = Rails.root.join("test")
149
+ funicular_test_dir = test_dir.join("funicular")
150
+ client_test_dir = funicular_test_dir.join("client")
151
+ FileUtils.mkdir_p(client_test_dir)
152
+
153
+ test_helper = test_dir.join("test_helper.rb")
154
+ unless File.exist?(test_helper)
155
+ File.write(test_helper, <<~TEST_HELPER)
156
+ ENV["RAILS_ENV"] ||= "test"
157
+
158
+ require_relative "../config/environment"
159
+ require "rails/test_help"
160
+ TEST_HELPER
161
+ puts "Installed #{test_helper}"
162
+ end
163
+
164
+ application_test = funicular_test_dir.join("application_test.rb")
165
+ unless File.exist?(application_test)
166
+ File.write(application_test, <<~APPLICATION_TEST)
167
+ require_relative "../test_helper"
168
+ require "funicular/testing"
169
+
170
+ class FunicularApplicationTest < ActiveSupport::TestCase
171
+ test "client-side Funicular tests" do
172
+ result = Funicular::Testing.run!(timeout_ms: 10_000)
173
+ Funicular::Testing.assert_picotests(self, result)
174
+ end
175
+ end
176
+ APPLICATION_TEST
177
+ puts "Installed #{application_test}"
178
+ end
179
+
180
+ keep_file = client_test_dir.join(".keep")
181
+ FileUtils.touch(keep_file) unless File.exist?(keep_file)
182
+
183
+ package_json = Rails.root.join("package.json")
184
+ package = if File.exist?(package_json)
185
+ JSON.parse(File.read(package_json))
186
+ else
187
+ { "private" => true }
188
+ end
189
+ package["devDependencies"] ||= {}
190
+ package["devDependencies"]["jsdom"] ||= "^26.1.0"
191
+ File.write(package_json, JSON.pretty_generate(package) + "\n")
192
+ puts "Updated #{package_json}"
193
+
194
+ gitignore = Rails.root.join(".gitignore")
195
+ if File.exist?(gitignore)
196
+ content = File.read(gitignore)
197
+ unless content.lines.any? { |line| line.chomp == "/node_modules" }
198
+ File.open(gitignore, "a") do |f|
199
+ f.puts
200
+ f.puts "# Ignore Node dependencies."
201
+ f.puts "/node_modules"
202
+ end
203
+ puts "Updated #{gitignore}"
204
+ end
205
+ end
206
+
207
+ puts "Installed Funicular test support:"
208
+ puts " - #{application_test}"
209
+ puts " - #{client_test_dir}"
210
+ puts " - jsdom dev dependency in #{package_json}"
211
+ end
129
212
  end
130
213
  end
131
214
 
@@ -0,0 +1,16 @@
1
+ class GreetingComponent < Funicular::Component
2
+ def initialize_state
3
+ { title: "Default Title", items: [] }
4
+ end
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"] }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,5 @@
1
+ Funicular.start(container: "app") do |router|
2
+ router.get("/greet", to: GreetingComponent, as: "greet")
3
+ router.get("/greet/:id", to: GreetingComponent, as: "greet_item")
4
+ router.set_default("/greet")
5
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Exercises the hydration mismatch guard under CRuby. The structural decision
6
+ # (`hydration_match?`) and the dev warning (`warn_hydration_mismatch`) are pure
7
+ # Ruby, so they are tested here without a DOM. The actual recovery swap done by
8
+ # `full_render_fallback` (Renderer + replaceChild) is JS-only and is covered by
9
+ # the browser/manual verification step, not here.
10
+ #
11
+ # A plain Hash stands in for the server DOM node: hydration_match? only reads
12
+ # `dom_element[:tagName]`, which a Hash answers the same way a JS::Element does.
13
+ class HydrationMatchTest < Minitest::Test
14
+ def setup
15
+ Funicular::SSR::Runtime.load_framework!
16
+ Funicular.env = "development"
17
+ end
18
+
19
+ def teardown
20
+ Funicular.env = "development"
21
+ end
22
+
23
+ # Builds the probe at runtime (Class.new) so the suite matches its picotest
24
+ # twin and never references Funicular::Component at load time. render is never
25
+ # invoked here (we feed vnodes directly); it only satisfies the abstract API.
26
+ def probe
27
+ klass = Class.new(Funicular::Component) do
28
+ def render
29
+ div { "x" }
30
+ end
31
+
32
+ def match?(vnode, dom)
33
+ hydration_match?(vnode, dom)
34
+ end
35
+
36
+ def warn_for(vnode, dom)
37
+ warn_hydration_mismatch(vnode, dom)
38
+ end
39
+ end
40
+ klass.new
41
+ end
42
+
43
+ def el(tag)
44
+ Funicular::VDOM::Element.new(tag, {}, ["x"])
45
+ end
46
+
47
+ # --- hydration_match? (the decision that drives the fallback) ---------
48
+
49
+ def test_matches_when_root_tags_agree
50
+ assert_equal true, probe.match?(el("div"), { tagName: "DIV" })
51
+ end
52
+
53
+ def test_detects_mismatched_root_tag
54
+ assert_equal false, probe.match?(el("div"), { tagName: "SPAN" })
55
+ end
56
+
57
+ def test_tag_comparison_is_case_insensitive
58
+ assert_equal true, probe.match?(el("h1"), { tagName: "H1" })
59
+ end
60
+
61
+ def test_non_element_vnode_is_treated_as_match
62
+ assert_equal true, probe.match?(Funicular::VDOM::Text.new("hi"), { tagName: "DIV" })
63
+ end
64
+
65
+ def test_missing_dom_tag_name_is_treated_as_match
66
+ assert_equal true, probe.match?(el("div"), {})
67
+ end
68
+
69
+ # --- warn_hydration_mismatch (dev-only diagnostics) -------------------
70
+
71
+ def test_warning_fires_in_development
72
+ out, _err = capture_io do
73
+ probe.warn_for(el("div"), { tagName: "SPAN" })
74
+ end
75
+ assert_includes out, "Hydration mismatch"
76
+ assert_includes out, "<div>"
77
+ assert_includes out, "<span>"
78
+ end
79
+
80
+ def test_warning_is_silent_in_production
81
+ Funicular.env = "production"
82
+ out, _err = capture_io do
83
+ probe.warn_for(el("div"), { tagName: "SPAN" })
84
+ end
85
+ assert_equal "", out
86
+ end
87
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ require_relative "test_helper"
7
+
8
+ class PluginTest < Minitest::Test
9
+ def test_registry_resolves_funicular_group_gems_and_syncs_assets
10
+ Dir.mktmpdir do |dir|
11
+ rails_root = File.join(dir, "app")
12
+ gem_root = File.join(dir, "funicular-datepicker")
13
+ FileUtils.mkdir_p(File.join(gem_root, "lib", "components"))
14
+ FileUtils.mkdir_p(File.join(gem_root, "assets"))
15
+ File.write(File.join(gem_root, "lib", "date_picker.rb"), "# plugin entry\n")
16
+ File.write(File.join(gem_root, "lib", "components", "date_picker_component.rb"), "# component\n")
17
+ File.write(File.join(gem_root, "assets", "date_picker.css"), "/* css */\n")
18
+
19
+ spec = Gem::Specification.new do |s|
20
+ s.name = "funicular-datepicker"
21
+ s.version = "0.1.0"
22
+ s.full_gem_path = gem_root
23
+ end
24
+
25
+ registry = Funicular::Plugin::Registry.new(rails_root)
26
+ registry.define_singleton_method(:funicular_specs) { [spec] }
27
+ registry.sync_assets
28
+
29
+ synced_css = File.join(
30
+ rails_root,
31
+ "app",
32
+ "assets",
33
+ "builds",
34
+ "funicular",
35
+ "plugins",
36
+ "funicular_datepicker",
37
+ "date_picker.css"
38
+ )
39
+ assert File.exist?(synced_css)
40
+
41
+ entries = registry.asset_entries
42
+ assert_equal 1, entries.size
43
+ assert_equal "css", entries.first["type"]
44
+ assert_equal "funicular/plugins/funicular_datepicker/date_picker.css", entries.first["logical_path"]
45
+ assert_equal [
46
+ File.join(gem_root, "lib", "components", "date_picker_component.rb"),
47
+ File.join(gem_root, "lib", "date_picker.rb")
48
+ ], registry.local_source_files
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "active_model"
5
+
6
+ # Exercises Funicular::Schema.validations_for: deriving client validation rules
7
+ # from an ActiveModel class, honoring the attribute allowlist and the per-kind
8
+ # denylist, skipping unsupported/conditional validators, and translating the
9
+ # `format` regexp for the JS RegExp engine.
10
+ class SchemaDerivationTest < Minitest::Test
11
+ class Account
12
+ include ActiveModel::Validations
13
+ attr_accessor :name, :email, :age, :role, :code, :score
14
+
15
+ # Custom validator (kind :even) stands in for any non-standard validator
16
+ # the client has no counterpart for; it must be skipped during derivation.
17
+ class EvenValidator < ActiveModel::EachValidator
18
+ def validate_each(record, attribute, value); end
19
+ end
20
+
21
+ validates :name, presence: true, length: { maximum: 30 }
22
+ validates :email, format: { with: /\A[^@\s]+@[^@\s]+\z/ }
23
+ validates :age, numericality: { only_integer: true, greater_than: 0 }
24
+ validates :role, inclusion: { in: %w[admin user] }
25
+ validates :code, presence: true, if: -> { false } # conditional -> skipped
26
+ validates :score, even: true # unsupported kind -> skipped
27
+ end
28
+
29
+ def derive(attrs, except: {})
30
+ Funicular::Schema.validations_for(Account, attrs, except: except)
31
+ end
32
+
33
+ def test_presence_and_length
34
+ result = derive(["name"])
35
+ assert_equal({ "presence" => true, "length" => { "maximum" => 30 } }, result["name"])
36
+ end
37
+
38
+ def test_only_listed_attributes_are_introspected
39
+ result = derive(["name"])
40
+ assert_equal ["name"], result.keys
41
+ end
42
+
43
+ def test_numericality_and_inclusion
44
+ result = derive(["age", "role"])
45
+ assert_equal({ "only_integer" => true, "greater_than" => 0 }, result["age"]["numericality"])
46
+ assert_equal({ "in" => %w[admin user] }, result["role"]["inclusion"])
47
+ end
48
+
49
+ def test_format_translates_ruby_anchors
50
+ result = derive(["email"])
51
+ fmt = result["email"]["format"]
52
+ # \A and \z become ^ and $ for JS RegExp.
53
+ assert_equal "^[^@\\s]+@[^@\\s]+$", fmt["with"]
54
+ end
55
+
56
+ def test_denylist_suppresses_a_kind
57
+ result = derive(["name"], except: { name: [:length] })
58
+ assert_equal({ "presence" => true }, result["name"])
59
+ end
60
+
61
+ def test_conditional_validator_is_skipped
62
+ result = derive(["code"])
63
+ assert_nil result["code"]
64
+ end
65
+
66
+ def test_unsupported_kind_is_skipped
67
+ result = derive(["score"])
68
+ assert_nil result["score"]
69
+ end
70
+
71
+ def test_build_inlines_validations_into_attributes
72
+ schema = Funicular::Schema.build(
73
+ Account,
74
+ attributes: {
75
+ "display_name" => { type: "string", readonly: false },
76
+ "name" => { type: "string", readonly: false }
77
+ },
78
+ endpoints: { "update" => { method: "PATCH", path: "/x/:id" } },
79
+ except: { name: [:length] }
80
+ )
81
+ # Attribute with no validators is untouched.
82
+ assert_equal({ type: "string", readonly: false }, schema[:attributes]["display_name"])
83
+ # Validators are merged inline; denylist drops :length here.
84
+ assert_equal(
85
+ { type: "string", readonly: false, validations: { "presence" => true } },
86
+ schema[:attributes]["name"]
87
+ )
88
+ assert_equal({ "update" => { method: "PATCH", path: "/x/:id" } }, schema[:endpoints])
89
+ end
90
+
91
+ def test_extended_regexp_is_skipped
92
+ klass = Class.new do
93
+ include ActiveModel::Validations
94
+ def self.name; "Extended"; end
95
+ attr_accessor :token
96
+ validates :token, format: { with: /
97
+ \A \d+ \z # an integer, written with x-mode whitespace
98
+ /x }
99
+ end
100
+ result = nil
101
+ capture_io do # silence the skip warning
102
+ result = Funicular::Schema.validations_for(klass, ["token"])
103
+ end
104
+ assert_nil result["token"]
105
+ end
106
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Exercises the SSR half of Funicular under CRuby: the shared HTMLSerializer
6
+ # and the full path of loading the mrblib runtime + a fixture app, then
7
+ # rendering a routed component to HTML with injected state.
8
+ class SSRTest < Minitest::Test
9
+ APP_DIR = File.expand_path("fixtures/funicular_app", __dir__)
10
+
11
+ def setup
12
+ Funicular::SSR::Runtime.load_framework!
13
+ end
14
+
15
+ # --- HTMLSerializer (pure Ruby) ---------------------------------------
16
+
17
+ def serialize(vnode)
18
+ Funicular::VDOM::HTMLSerializer.serialize(vnode)
19
+ end
20
+
21
+ def el(tag, props = {}, children = [])
22
+ Funicular::VDOM::Element.new(tag, props, children)
23
+ end
24
+
25
+ def test_serializes_element_with_attributes
26
+ assert_equal '<div class="box" id="x"></div>',
27
+ serialize(el("div", { class: "box", id: "x" }))
28
+ end
29
+
30
+ def test_escapes_text_content
31
+ assert_equal "<p>a &amp; b &lt;c&gt;</p>",
32
+ serialize(el("p", {}, ["a & b <c>"]))
33
+ end
34
+
35
+ def test_escapes_attribute_values
36
+ assert_equal '<div title="&quot;hi&quot;"></div>',
37
+ serialize(el("div", { title: '"hi"' }))
38
+ end
39
+
40
+ def test_skips_event_handlers
41
+ html = serialize(el("button", { onclick: :handle }, ["Go"]))
42
+ assert_equal "<button>Go</button>", html
43
+ end
44
+
45
+ def test_boolean_attribute_present_and_absent
46
+ assert_equal '<input disabled="disabled">',
47
+ serialize(el("input", { disabled: true }))
48
+ assert_equal "<input>",
49
+ serialize(el("input", { disabled: false }))
50
+ end
51
+
52
+ def test_void_element_self_closes
53
+ assert_equal "<br>", serialize(el("br"))
54
+ assert_equal '<img src="/a.png">', serialize(el("img", { src: "/a.png" }))
55
+ end
56
+
57
+ def test_blocks_javascript_uri
58
+ assert_equal "<a>x</a>",
59
+ serialize(el("a", { href: "javascript:alert(1)" }, ["x"]))
60
+ end
61
+
62
+ # --- Full SSR render (runtime + fixture app) --------------------------
63
+
64
+ def test_render_injects_server_state
65
+ result = Funicular::SSR.render(
66
+ path: "/greet",
67
+ state: { title: "Channels", items: [{ "id" => 1, "name" => "general" },
68
+ { "id" => 2, "name" => "random" }] },
69
+ source_dir: APP_DIR
70
+ )
71
+
72
+ assert_includes result[:html], "<h1>Channels</h1>"
73
+ assert_includes result[:html], "general"
74
+ assert_includes result[:html], "random"
75
+ assert_equal GreetingComponent, result[:component]
76
+ end
77
+
78
+ def test_render_uses_initialize_state_without_injection
79
+ result = Funicular::SSR.render(path: "/greet", source_dir: APP_DIR)
80
+ assert_includes result[:html], "<h1>Default Title</h1>"
81
+ end
82
+
83
+ def test_render_unmatched_route_returns_empty
84
+ result = Funicular::SSR.render(path: "/no/such/path", source_dir: APP_DIR)
85
+ assert_equal "", result[:html]
86
+ assert_nil result[:component]
87
+ end
88
+
89
+ def test_route_params_become_props
90
+ # Renders without raising; :id from the path is passed as a prop.
91
+ result = Funicular::SSR.render(path: "/greet/42", source_dir: APP_DIR)
92
+ assert_includes result[:html], "greeting"
93
+ end
94
+ end