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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +10 -2
- data/Rakefile +29 -0
- data/docs/architecture.md +113 -404
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/compiler.rb +23 -15
- data/lib/funicular/helpers/picoruby_helper.rb +65 -3
- data/lib/funicular/middleware.rb +34 -9
- data/lib/funicular/plugin.rb +147 -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/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +94 -75
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- 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 +3 -0
- 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 +87 -4
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -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/validations_test.rb +183 -0
- data/mrbgem.rake +1 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +24 -9
- data/mrblib/component.rb +172 -33
- data/mrblib/debug.rb +3 -0
- data/mrblib/differ.rb +47 -37
- data/mrblib/file_upload.rb +9 -1
- data/mrblib/form_builder.rb +21 -5
- data/mrblib/funicular.rb +97 -8
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +123 -29
- data/mrblib/model.rb +50 -0
- data/mrblib/patcher.rb +74 -8
- data/mrblib/router.rb +40 -3
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/sig/cable.rbs +1 -0
- data/sig/component.rbs +13 -5
- data/sig/funicular.rbs +14 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +21 -6
- data/sig/model.rbs +6 -1
- data/sig/patcher.rbs +4 -1
- data/sig/router.rbs +3 -2
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +6 -6
- metadata +47 -12
- data/docs/README.md +0 -419
- data/docs/advanced-features.md +0 -632
- data/docs/components-and-state.md +0 -539
- data/docs/data-fetching.md +0 -528
- data/docs/forms.md +0 -446
- data/docs/rails-integration.md +0 -426
- data/docs/realtime.md +0 -543
- data/docs/routing-and-navigation.md +0 -427
- data/docs/styling.md +0 -285
|
@@ -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
|
data/lib/tasks/funicular.rake
CHANGED
|
@@ -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::
|
|
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
|
|
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,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 & b <c></p>",
|
|
32
|
+
serialize(el("p", {}, ["a & b <c>"]))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_escapes_attribute_values
|
|
36
|
+
assert_equal '<div title=""hi""></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
|