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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -0
- data/README.md +66 -20
- data/Rakefile +103 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/architecture.md +118 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +143 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +112 -0
- data/lib/funicular/middleware.rb +123 -0
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -0
- data/lib/funicular/vendor/picorbc/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +32 -1
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +218 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/test_helper.rb +7 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +16 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +432 -0
- data/mrblib/component.rb +1050 -0
- data/mrblib/debug.rb +208 -0
- data/mrblib/differ.rb +254 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +192 -0
- data/mrblib/form_builder.rb +300 -0
- data/mrblib/funicular.rb +245 -0
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +183 -0
- data/mrblib/model.rb +196 -0
- data/mrblib/patcher.rb +269 -0
- data/mrblib/router.rb +266 -0
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +66 -0
- data/sig/component.rbs +149 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +24 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +37 -0
- data/sig/model.rbs +28 -0
- data/sig/patcher.rbs +18 -0
- data/sig/router.rbs +44 -0
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/styles.rbs +25 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +59 -0
- 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,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,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,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
|