funicular 0.0.1 → 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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +66 -20
  4. data/Rakefile +103 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/architecture.md +118 -0
  15. data/exe/funicular +32 -0
  16. data/lib/funicular/assets/funicular.css +23 -0
  17. data/lib/funicular/assets/funicular.rb +21 -0
  18. data/lib/funicular/assets/funicular_debug.css +73 -0
  19. data/lib/funicular/assets/funicular_debug.js +183 -0
  20. data/lib/funicular/commands/routes.rb +69 -0
  21. data/lib/funicular/compiler.rb +143 -0
  22. data/lib/funicular/configuration.rb +76 -0
  23. data/lib/funicular/helpers/picoruby_helper.rb +112 -0
  24. data/lib/funicular/middleware.rb +123 -0
  25. data/lib/funicular/plugin.rb +147 -0
  26. data/lib/funicular/railtie.rb +26 -0
  27. data/lib/funicular/route_parser.rb +137 -0
  28. data/lib/funicular/schema.rb +167 -0
  29. data/lib/funicular/ssr/runtime.rb +101 -0
  30. data/lib/funicular/ssr.rb +51 -0
  31. data/lib/funicular/testing/node_runner.mjs +293 -0
  32. data/lib/funicular/testing/node_runner.rb +190 -0
  33. data/lib/funicular/testing.rb +22 -0
  34. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  35. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  37. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  38. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  39. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  41. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  42. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  44. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  45. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  46. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  47. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  48. data/lib/funicular/version.rb +1 -1
  49. data/lib/funicular.rb +32 -1
  50. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  51. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  52. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  53. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  54. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  55. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  56. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  57. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  58. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  59. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  60. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  61. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  62. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  63. data/lib/tasks/funicular.rake +218 -0
  64. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  65. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  66. data/minitest/funicular_test.rb +13 -0
  67. data/minitest/hydration_test.rb +87 -0
  68. data/minitest/plugin_test.rb +51 -0
  69. data/minitest/schema_test.rb +106 -0
  70. data/minitest/ssr_test.rb +94 -0
  71. data/minitest/test_helper.rb +7 -0
  72. data/minitest/validations_test.rb +183 -0
  73. data/mrbgem.rake +16 -0
  74. data/mrblib/0_validations.rb +206 -0
  75. data/mrblib/1_validators.rb +180 -0
  76. data/mrblib/cable.rb +432 -0
  77. data/mrblib/component.rb +1050 -0
  78. data/mrblib/debug.rb +208 -0
  79. data/mrblib/differ.rb +254 -0
  80. data/mrblib/environment_inquirer.rb +34 -0
  81. data/mrblib/error_boundary.rb +125 -0
  82. data/mrblib/file_upload.rb +192 -0
  83. data/mrblib/form_builder.rb +300 -0
  84. data/mrblib/funicular.rb +245 -0
  85. data/mrblib/html_serializer.rb +121 -0
  86. data/mrblib/http.rb +183 -0
  87. data/mrblib/model.rb +196 -0
  88. data/mrblib/patcher.rb +269 -0
  89. data/mrblib/router.rb +266 -0
  90. data/mrblib/store.rb +304 -0
  91. data/mrblib/store_collection.rb +171 -0
  92. data/mrblib/store_singleton.rb +79 -0
  93. data/mrblib/styles.rb +83 -0
  94. data/mrblib/vdom.rb +273 -0
  95. data/sig/cable.rbs +66 -0
  96. data/sig/component.rbs +149 -0
  97. data/sig/debug.rbs +28 -0
  98. data/sig/differ.rbs +18 -0
  99. data/sig/environment_iquirer.rbs +10 -0
  100. data/sig/error_boundary.rbs +14 -0
  101. data/sig/file_upload.rbs +18 -0
  102. data/sig/form_builder.rbs +29 -0
  103. data/sig/funicular.rbs +24 -1
  104. data/sig/html_serializer.rbs +20 -0
  105. data/sig/http.rbs +37 -0
  106. data/sig/model.rbs +28 -0
  107. data/sig/patcher.rbs +18 -0
  108. data/sig/router.rbs +44 -0
  109. data/sig/store.rbs +89 -0
  110. data/sig/store_collection.rbs +43 -0
  111. data/sig/store_singleton.rbs +19 -0
  112. data/sig/styles.rbs +25 -0
  113. data/sig/validations.rbs +103 -0
  114. data/sig/vdom.rbs +59 -0
  115. metadata +154 -8
@@ -0,0 +1,64 @@
1
+ class FunicularChatComponentTest < Funicular::Testing::DOMTest
2
+ def setup
3
+ super
4
+ Funicular::HTTP.__test_messages = [
5
+ { "id" => 1, "name" => "Alice", "body" => "Hello from Rails" }
6
+ ]
7
+ end
8
+
9
+ def test_renders_loaded_messages
10
+ mount FunicularChatComponent
11
+ drain
12
+
13
+ assert_text "Funicular Chat"
14
+ assert_text "Hello from Rails"
15
+ end
16
+ end
17
+
18
+ module Funicular
19
+ module HTTP
20
+ class << self
21
+ attr_accessor :__test_messages
22
+
23
+ def get(url, &block)
24
+ block.call(Response.new(200, __test_messages))
25
+ end
26
+
27
+ def post(url, body = nil, &block)
28
+ block.call(Response.new(201, { "id" => 2, "name" => body[:message][:name], "body" => body[:message][:body] }))
29
+ end
30
+ end
31
+ end
32
+
33
+ module Cable
34
+ class TestConsumer
35
+ attr_reader :subscriptions
36
+
37
+ def initialize
38
+ @subscriptions = TestSubscriptions.new
39
+ end
40
+
41
+ def cleanup
42
+ end
43
+ end
44
+
45
+ class TestSubscriptions
46
+ def create(params, &block)
47
+ TestSubscription.new
48
+ end
49
+ end
50
+
51
+ class TestSubscription
52
+ def on_connected(&block)
53
+ block.call
54
+ end
55
+
56
+ def unsubscribe
57
+ end
58
+ end
59
+
60
+ def self.create_consumer(url)
61
+ TestConsumer.new
62
+ end
63
+ end
64
+ end
@@ -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>
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :funicular do
4
+ desc "Compile Funicular Ruby files to .mrb format"
5
+ task compile: :environment do
6
+ require "funicular/compiler"
7
+ require "funicular/plugin"
8
+
9
+ source_dir = Rails.root.join("app", "funicular")
10
+ output_file = Rails.root.join("app", "assets", "builds", "app.mrb")
11
+ debug_mode = !Rails.env.production?
12
+
13
+ unless Dir.exist?(source_dir)
14
+ puts "Skipping Funicular compilation: #{source_dir} does not exist"
15
+ next
16
+ end
17
+
18
+ begin
19
+ plugin_registry = Funicular::Plugin::Registry.new(Rails.root)
20
+ plugin_registry.validate!
21
+ plugin_registry.sync_assets
22
+ compiler = Funicular::Compiler.new(
23
+ source_dir: source_dir,
24
+ output_file: output_file,
25
+ debug_mode: debug_mode,
26
+ prepend_source_files: plugin_registry.local_source_files
27
+ )
28
+ compiler.compile
29
+ rescue Funicular::Plugin::Error => e
30
+ puts "ERROR: #{e.message}"
31
+ exit 1
32
+ rescue Funicular::Compiler::PicorbcMissingError => e
33
+ puts "ERROR: #{e.message}"
34
+ exit 1
35
+ rescue => e
36
+ puts "ERROR: Failed to compile Funicular application"
37
+ puts e.message
38
+ puts e.backtrace.join("\n")
39
+ exit 1
40
+ end
41
+ end
42
+
43
+ desc "Show all Funicular routes"
44
+ task routes: :environment do
45
+ require "funicular/commands/routes"
46
+
47
+ begin
48
+ Funicular::Commands::Routes.new.execute
49
+ rescue => e
50
+ puts "ERROR: Failed to display routes"
51
+ puts e.message
52
+ puts e.backtrace.join("\n")
53
+ exit 1
54
+ end
55
+ end
56
+
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
59
+ puts ""
60
+ puts "All Funicular assets installed."
61
+ puts ""
62
+ puts "Next steps:"
63
+ puts " 1. In your layout, replace any hardcoded PicoRuby <script> tag with:"
64
+ puts ' <%= picoruby_include_tag %>'
65
+ puts ""
66
+ puts " 2. (Optional) Edit config/initializers/funicular.rb to choose the source"
67
+ puts " for each environment (:local_debug, :local_dist, :cdn)."
68
+ puts ""
69
+ puts " 3. (Optional, development only) Add to your layout to enable"
70
+ puts " the component highlighter:"
71
+ puts ' <% if Rails.env.development? %>'
72
+ puts ' <%= javascript_include_tag "funicular_debug", "data-turbo-track": "reload" %>'
73
+ puts ' <%= stylesheet_link_tag "funicular_debug", "data-turbo-track": "reload" %>'
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`."
79
+ end
80
+
81
+ namespace :install do
82
+ desc "Install Funicular debug JS/CSS assets and the gem initializer"
83
+ task :debug_assets do
84
+ require "fileutils"
85
+
86
+ javascripts_dir = Rails.root.join("app", "assets", "javascripts")
87
+ stylesheets_dir = Rails.root.join("app", "assets", "stylesheets")
88
+ initializers_dir = Rails.root.join("config", "initializers")
89
+
90
+ FileUtils.mkdir_p(javascripts_dir)
91
+ FileUtils.mkdir_p(stylesheets_dir)
92
+ FileUtils.mkdir_p(initializers_dir)
93
+
94
+ source_js = File.expand_path("../funicular/assets/funicular_debug.js", __dir__)
95
+ source_css = File.expand_path("../funicular/assets/funicular_debug.css", __dir__)
96
+ source_initializer = File.expand_path("../funicular/assets/funicular.rb", __dir__)
97
+
98
+ dest_js = javascripts_dir.join("funicular_debug.js")
99
+ dest_css = stylesheets_dir.join("funicular_debug.css")
100
+ dest_initializer = initializers_dir.join("funicular.rb")
101
+
102
+ FileUtils.cp(source_js, dest_js)
103
+ FileUtils.cp(source_css, dest_css)
104
+ FileUtils.cp(source_initializer, dest_initializer)
105
+
106
+ puts "Installed Funicular debug assets:"
107
+ puts " - #{dest_js}"
108
+ puts " - #{dest_css}"
109
+ puts " - #{dest_initializer}"
110
+ end
111
+
112
+ desc "Install vendored PicoRuby.wasm artifacts (dist + debug) into public/picoruby/"
113
+ task :wasm do
114
+ require "fileutils"
115
+
116
+ vendor_root = File.expand_path("../funicular/vendor/picoruby", __dir__)
117
+ unless Dir.exist?(vendor_root)
118
+ abort "Vendored PicoRuby artifacts not found at #{vendor_root}. " \
119
+ "Reinstall the funicular gem or run `rake funicular:vendor` from a checkout."
120
+ end
121
+
122
+ dest_root = Rails.root.join("public", "picoruby")
123
+ FileUtils.mkdir_p(dest_root)
124
+
125
+ %w[dist debug].each do |variant|
126
+ src = File.join(vendor_root, variant)
127
+ dst = dest_root.join(variant)
128
+
129
+ unless Dir.exist?(src)
130
+ warn "Skipping #{variant}: #{src} not found"
131
+ next
132
+ end
133
+
134
+ FileUtils.rm_rf(dst)
135
+ FileUtils.mkdir_p(dst)
136
+ FileUtils.cp_r(File.join(src, "."), dst)
137
+
138
+ puts "Installed PicoRuby #{variant} build to #{dst}"
139
+ end
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
212
+ end
213
+ end
214
+
215
+ # Hook into assets:precompile for production deployment
216
+ if Rake::Task.task_defined?("assets:precompile")
217
+ Rake::Task["assets:precompile"].enhance(["funicular:compile"])
218
+ end
@@ -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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class FunicularTest < Minitest::Test
6
+ def test_VERSION
7
+ assert ::Funicular.const_defined?(:VERSION)
8
+ end
9
+
10
+ def test_something_useful
11
+ assert("expected"!="actual")
12
+ end
13
+ 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