funicular 0.1.0 → 0.2.1
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 +12 -4
- data/Rakefile +29 -0
- data/docs/architecture.md +113 -404
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/compiler.rb +25 -18
- 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 +115 -87
- 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 +6908 -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/form_for_test.rb +106 -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 +246 -47
- data/mrblib/debug.rb +3 -0
- data/mrblib/differ.rb +47 -37
- data/mrblib/file_upload.rb +9 -1
- data/mrblib/form_builder.rb +28 -6
- 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 +78 -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/mrblib/vdom.rb +2 -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 +48 -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,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,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class FormForTest < Minitest::Test
|
|
6
|
+
def setup
|
|
7
|
+
Funicular::SSR::Runtime.load_framework!
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def component_class
|
|
11
|
+
Class.new(Funicular::Component) do
|
|
12
|
+
attr_reader :submitted
|
|
13
|
+
|
|
14
|
+
def initialize_state
|
|
15
|
+
{ comment: { body: "" } }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def handle_submit(data)
|
|
19
|
+
@submitted = data
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render
|
|
23
|
+
form_for(:comment, on_submit: :handle_submit) do |f|
|
|
24
|
+
f.textarea(:body)
|
|
25
|
+
f.submit("Post")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class SubmitEvent
|
|
32
|
+
attr_reader :target
|
|
33
|
+
|
|
34
|
+
def initialize(target)
|
|
35
|
+
@target = target
|
|
36
|
+
@prevented = false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def preventDefault
|
|
40
|
+
@prevented = true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def prevented?
|
|
44
|
+
@prevented
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def [](key)
|
|
48
|
+
key == :target ? @target : nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class FormElement
|
|
53
|
+
def initialize(elements)
|
|
54
|
+
@elements = Elements.new(elements)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def [](key)
|
|
58
|
+
key == :elements ? @elements : nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class Elements
|
|
63
|
+
def initialize(elements)
|
|
64
|
+
@elements = elements
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def [](key)
|
|
68
|
+
return @elements.length if key == :length
|
|
69
|
+
|
|
70
|
+
@elements[key]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class Field
|
|
75
|
+
def initialize(attrs)
|
|
76
|
+
@attrs = attrs
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def [](key)
|
|
80
|
+
@attrs[key]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_form_for_submits_current_dom_values
|
|
85
|
+
component = component_class.new
|
|
86
|
+
vnode = component.build_vdom
|
|
87
|
+
event = SubmitEvent.new(
|
|
88
|
+
FormElement.new([
|
|
89
|
+
Field.new(name: "body", type: "textarea", value: "fresh")
|
|
90
|
+
])
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
vnode.props[:onsubmit].call(event)
|
|
94
|
+
|
|
95
|
+
assert_predicate event, :prevented?
|
|
96
|
+
assert_equal({ body: "fresh" }, component.submitted)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def test_form_builder_fields_include_names
|
|
100
|
+
component = component_class.new
|
|
101
|
+
vnode = component.build_vdom
|
|
102
|
+
textarea = vnode.children[0].children[0]
|
|
103
|
+
|
|
104
|
+
assert_equal "body", textarea.props[:name]
|
|
105
|
+
end
|
|
106
|
+
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
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# Load the mrblib runtime before defining model subclasses below, since their
|
|
6
|
+
# class bodies reference Funicular::Model and call `validates` at load time.
|
|
7
|
+
Funicular::SSR::Runtime.load_framework!
|
|
8
|
+
|
|
9
|
+
# Exercises the client-side validation framework under CRuby (the same code
|
|
10
|
+
# runs in the browser under PicoRuby; see test/validations_test.rb).
|
|
11
|
+
class ValidationsTest < Minitest::Test
|
|
12
|
+
# A fresh, isolated model class with the given schema so validators declared
|
|
13
|
+
# in one test never leak into another.
|
|
14
|
+
def model_class(attributes = { "name" => false, "age" => false }, endpoints = {})
|
|
15
|
+
klass = Class.new(Funicular::Model)
|
|
16
|
+
attrs = {}
|
|
17
|
+
attributes.each { |name, readonly| attrs[name] = { "type" => "string", "readonly" => readonly } }
|
|
18
|
+
klass.load_schema("attributes" => attrs, "endpoints" => endpoints)
|
|
19
|
+
klass
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# --- individual validators -------------------------------------------
|
|
23
|
+
|
|
24
|
+
def test_presence
|
|
25
|
+
k = model_class
|
|
26
|
+
k.class_eval { validates :name, presence: true }
|
|
27
|
+
blank = k.new("name" => "")
|
|
28
|
+
assert_equal false, blank.valid?
|
|
29
|
+
assert_equal ["can't be blank"], blank.errors[:name]
|
|
30
|
+
assert_equal true, k.new("name" => "Alice").valid?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_length_maximum_and_minimum
|
|
34
|
+
k = model_class
|
|
35
|
+
k.class_eval { validates :name, length: { minimum: 2, maximum: 5 } }
|
|
36
|
+
|
|
37
|
+
short = k.new("name" => "a"); short.valid?
|
|
38
|
+
assert_equal ["is too short (minimum is 2 characters)"], short.errors[:name]
|
|
39
|
+
|
|
40
|
+
long = k.new("name" => "abcdef"); long.valid?
|
|
41
|
+
assert_equal ["is too long (maximum is 5 characters)"], long.errors[:name]
|
|
42
|
+
|
|
43
|
+
assert_equal true, k.new("name" => "abc").valid?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_format
|
|
47
|
+
k = model_class
|
|
48
|
+
k.class_eval { validates :name, format: { with: /^[a-z]+$/ } }
|
|
49
|
+
assert_equal true, k.new("name" => "abc").valid?
|
|
50
|
+
bad = k.new("name" => "ABC"); bad.valid?
|
|
51
|
+
assert_equal ["is invalid"], bad.errors[:name]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_numericality
|
|
55
|
+
k = model_class
|
|
56
|
+
k.class_eval { validates :age, numericality: { only_integer: true, greater_than: 0 } }
|
|
57
|
+
nan = k.new("age" => "abc"); nan.valid?
|
|
58
|
+
assert_equal ["is not a number"], nan.errors[:age]
|
|
59
|
+
assert_equal true, k.new("age" => "10").valid?
|
|
60
|
+
low = k.new("age" => "0"); low.valid?
|
|
61
|
+
assert_equal ["must be greater than 0"], low.errors[:age]
|
|
62
|
+
frac = k.new("age" => "1.5"); frac.valid?
|
|
63
|
+
assert_equal ["must be an integer"], frac.errors[:age]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def test_inclusion_and_exclusion
|
|
67
|
+
inc = model_class
|
|
68
|
+
inc.class_eval { validates :name, inclusion: { in: ["a", "b"] } }
|
|
69
|
+
assert_equal true, inc.new("name" => "a").valid?
|
|
70
|
+
assert_equal false, inc.new("name" => "z").valid?
|
|
71
|
+
|
|
72
|
+
exc = model_class
|
|
73
|
+
exc.class_eval { validates :name, exclusion: { in: ["admin"] } }
|
|
74
|
+
assert_equal false, exc.new("name" => "admin").valid?
|
|
75
|
+
assert_equal true, exc.new("name" => "alice").valid?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def test_allow_blank_skips_validation
|
|
79
|
+
k = model_class
|
|
80
|
+
k.class_eval { validates :name, length: { minimum: 3 }, allow_blank: true }
|
|
81
|
+
assert_equal true, k.new("name" => "").valid?
|
|
82
|
+
assert_equal false, k.new("name" => "ab").valid?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_valid_clears_previous_errors
|
|
86
|
+
k = model_class
|
|
87
|
+
k.class_eval { validates :name, presence: true }
|
|
88
|
+
m = k.new("name" => "")
|
|
89
|
+
m.valid?
|
|
90
|
+
m.instance_variable_set("@name", "now set")
|
|
91
|
+
assert_equal true, m.valid?
|
|
92
|
+
assert_equal [], m.errors[:name]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# --- schema-derived validators + merge -------------------------------
|
|
96
|
+
|
|
97
|
+
def test_load_schema_merges_and_dedupes
|
|
98
|
+
k = Class.new(Funicular::Model)
|
|
99
|
+
k.class_eval { validates :name, presence: true } # client-declared
|
|
100
|
+
k.load_schema(
|
|
101
|
+
"attributes" => {
|
|
102
|
+
"name" => { "type" => "string", "readonly" => false },
|
|
103
|
+
"age" => { "type" => "string", "readonly" => false }
|
|
104
|
+
},
|
|
105
|
+
"endpoints" => {},
|
|
106
|
+
"validations" => {
|
|
107
|
+
"name" => { "presence" => true, "length" => { "maximum" => 3 } },
|
|
108
|
+
"age" => { "presence" => true }
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
name_kinds = k.validators_on(:name).map(&:kind).sort
|
|
113
|
+
# presence appears once (client wins, schema presence deduped) + length merged
|
|
114
|
+
assert_equal [:length, :presence], name_kinds
|
|
115
|
+
assert_equal [:presence], k.validators_on(:age).map(&:kind)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def test_inline_attribute_validations_are_registered
|
|
119
|
+
# The shape produced by Funicular::Schema.build: validations nested inside
|
|
120
|
+
# each attribute entry.
|
|
121
|
+
k = Class.new(Funicular::Model)
|
|
122
|
+
k.load_schema(
|
|
123
|
+
"attributes" => {
|
|
124
|
+
"name" => {
|
|
125
|
+
"type" => "string", "readonly" => false,
|
|
126
|
+
"validations" => { "presence" => true, "length" => { "maximum" => 3 } }
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
"endpoints" => {}
|
|
130
|
+
)
|
|
131
|
+
assert_equal [:length, :presence], k.validators_on(:name).map(&:kind).sort
|
|
132
|
+
assert_equal false, k.new("name" => "toolong").valid?
|
|
133
|
+
assert_equal true, k.new("name" => "ok").valid?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def test_format_from_schema_rebuilds_regexp
|
|
137
|
+
k = Class.new(Funicular::Model)
|
|
138
|
+
k.load_schema(
|
|
139
|
+
"attributes" => { "name" => { "type" => "string", "readonly" => false } },
|
|
140
|
+
"endpoints" => {},
|
|
141
|
+
"validations" => { "name" => { "format" => { "with" => "^[a-z]+$", "flags" => "i" } } }
|
|
142
|
+
)
|
|
143
|
+
assert_equal true, k.new("name" => "ABC").valid? # 'i' flag honored
|
|
144
|
+
assert_equal false, k.new("name" => "123").valid?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# --- create/update auto-validation -----------------------------------
|
|
148
|
+
|
|
149
|
+
def test_create_short_circuits_when_invalid
|
|
150
|
+
k = model_class({ "name" => false }, "create" => { "method" => "POST", "path" => "/things" })
|
|
151
|
+
k.class_eval { validates :name, presence: true }
|
|
152
|
+
|
|
153
|
+
got_instance = :unset
|
|
154
|
+
got_error = :unset
|
|
155
|
+
# Invalid -> returns before any HTTP call, yields the errors.
|
|
156
|
+
k.create({ "name" => "" }) do |instance, error|
|
|
157
|
+
got_instance = instance
|
|
158
|
+
got_error = error
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
assert_nil got_instance
|
|
162
|
+
assert_instance_of Funicular::Model::Errors, got_error
|
|
163
|
+
assert_equal ["can't be blank"], got_error[:name]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def test_update_short_circuits_when_invalid
|
|
167
|
+
k = model_class({ "name" => false }, "update" => { "method" => "PATCH", "path" => "/things/:id" })
|
|
168
|
+
k.class_eval { validates :name, presence: true }
|
|
169
|
+
|
|
170
|
+
m = k.new("name" => "ok")
|
|
171
|
+
m.instance_variable_set("@id", 1)
|
|
172
|
+
|
|
173
|
+
got_success = :unset
|
|
174
|
+
got_result = :unset
|
|
175
|
+
m.update("name" => "") do |success, result|
|
|
176
|
+
got_success = success
|
|
177
|
+
got_result = result
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
assert_equal false, got_success
|
|
181
|
+
assert_equal ["can't be blank"], got_result[:name]
|
|
182
|
+
end
|
|
183
|
+
end
|
data/mrbgem.rake
CHANGED
|
@@ -6,6 +6,7 @@ MRuby::Gem::Specification.new('picoruby-funicular') do |spec|
|
|
|
6
6
|
unless ENV['TEST_TASK']
|
|
7
7
|
spec.add_dependency 'picoruby-wasm'
|
|
8
8
|
end
|
|
9
|
+
spec.add_dependency 'picoruby-indexeddb'
|
|
9
10
|
spec.add_dependency 'picoruby-json'
|
|
10
11
|
spec.add_dependency 'mruby-object-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-object-ext"
|
|
11
12
|
spec.add_dependency 'mruby-hash-ext', gemdir: "#{MRUBY_ROOT}/mrbgems/picoruby-mruby/lib/mruby/mrbgems/mruby-hash-ext"
|