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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +12 -4
  4. data/Rakefile +29 -0
  5. data/docs/architecture.md +113 -404
  6. data/lib/funicular/assets/funicular.css +23 -0
  7. data/lib/funicular/compiler.rb +25 -18
  8. data/lib/funicular/helpers/picoruby_helper.rb +65 -3
  9. data/lib/funicular/middleware.rb +34 -9
  10. data/lib/funicular/plugin.rb +147 -0
  11. data/lib/funicular/schema.rb +167 -0
  12. data/lib/funicular/ssr/runtime.rb +101 -0
  13. data/lib/funicular/ssr.rb +51 -0
  14. data/lib/funicular/testing/node_runner.mjs +293 -0
  15. data/lib/funicular/testing/node_runner.rb +190 -0
  16. data/lib/funicular/testing.rb +22 -0
  17. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  18. data/lib/funicular/vendor/picoruby/debug/picoruby.js +115 -87
  19. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  20. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  21. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  22. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  23. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6908 -0
  24. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  25. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  26. data/lib/funicular/version.rb +1 -1
  27. data/lib/funicular.rb +3 -0
  28. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  29. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  30. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  31. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  32. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  33. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  34. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  35. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  37. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  38. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  39. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  40. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  41. data/lib/tasks/funicular.rake +87 -4
  42. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  43. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  44. data/minitest/form_for_test.rb +106 -0
  45. data/minitest/hydration_test.rb +87 -0
  46. data/minitest/plugin_test.rb +51 -0
  47. data/minitest/schema_test.rb +106 -0
  48. data/minitest/ssr_test.rb +94 -0
  49. data/minitest/validations_test.rb +183 -0
  50. data/mrbgem.rake +1 -0
  51. data/mrblib/0_validations.rb +206 -0
  52. data/mrblib/1_validators.rb +180 -0
  53. data/mrblib/cable.rb +24 -9
  54. data/mrblib/component.rb +246 -47
  55. data/mrblib/debug.rb +3 -0
  56. data/mrblib/differ.rb +47 -37
  57. data/mrblib/file_upload.rb +9 -1
  58. data/mrblib/form_builder.rb +28 -6
  59. data/mrblib/funicular.rb +97 -8
  60. data/mrblib/html_serializer.rb +121 -0
  61. data/mrblib/http.rb +123 -29
  62. data/mrblib/model.rb +50 -0
  63. data/mrblib/patcher.rb +78 -8
  64. data/mrblib/router.rb +40 -3
  65. data/mrblib/store.rb +304 -0
  66. data/mrblib/store_collection.rb +171 -0
  67. data/mrblib/store_singleton.rb +79 -0
  68. data/mrblib/vdom.rb +2 -0
  69. data/sig/cable.rbs +1 -0
  70. data/sig/component.rbs +13 -5
  71. data/sig/funicular.rbs +14 -1
  72. data/sig/html_serializer.rbs +20 -0
  73. data/sig/http.rbs +21 -6
  74. data/sig/model.rbs +6 -1
  75. data/sig/patcher.rbs +4 -1
  76. data/sig/router.rbs +3 -2
  77. data/sig/store.rbs +89 -0
  78. data/sig/store_collection.rbs +43 -0
  79. data/sig/store_singleton.rbs +19 -0
  80. data/sig/validations.rbs +103 -0
  81. data/sig/vdom.rbs +6 -6
  82. metadata +48 -12
  83. data/docs/README.md +0 -419
  84. data/docs/advanced-features.md +0 -632
  85. data/docs/components-and-state.md +0 -539
  86. data/docs/data-fetching.md +0 -528
  87. data/docs/forms.md +0 -446
  88. data/docs/rails-integration.md +0 -426
  89. data/docs/realtime.md +0 -543
  90. data/docs/routing-and-navigation.md +0 -427
  91. 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,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,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 &amp; b &lt;c&gt;</p>",
32
+ serialize(el("p", {}, ["a & b <c>"]))
33
+ end
34
+
35
+ def test_escapes_attribute_values
36
+ assert_equal '<div title="&quot;hi&quot;"></div>',
37
+ serialize(el("div", { title: '"hi"' }))
38
+ end
39
+
40
+ def test_skips_event_handlers
41
+ html = serialize(el("button", { onclick: :handle }, ["Go"]))
42
+ assert_equal "<button>Go</button>", html
43
+ end
44
+
45
+ def test_boolean_attribute_present_and_absent
46
+ assert_equal '<input disabled="disabled">',
47
+ serialize(el("input", { disabled: true }))
48
+ assert_equal "<input>",
49
+ serialize(el("input", { disabled: false }))
50
+ end
51
+
52
+ def test_void_element_self_closes
53
+ assert_equal "<br>", serialize(el("br"))
54
+ assert_equal '<img src="/a.png">', serialize(el("img", { src: "/a.png" }))
55
+ end
56
+
57
+ def test_blocks_javascript_uri
58
+ assert_equal "<a>x</a>",
59
+ serialize(el("a", { href: "javascript:alert(1)" }, ["x"]))
60
+ end
61
+
62
+ # --- Full SSR render (runtime + fixture app) --------------------------
63
+
64
+ def test_render_injects_server_state
65
+ result = Funicular::SSR.render(
66
+ path: "/greet",
67
+ state: { title: "Channels", items: [{ "id" => 1, "name" => "general" },
68
+ { "id" => 2, "name" => "random" }] },
69
+ source_dir: APP_DIR
70
+ )
71
+
72
+ assert_includes result[:html], "<h1>Channels</h1>"
73
+ assert_includes result[:html], "general"
74
+ assert_includes result[:html], "random"
75
+ assert_equal GreetingComponent, result[:component]
76
+ end
77
+
78
+ def test_render_uses_initialize_state_without_injection
79
+ result = Funicular::SSR.render(path: "/greet", source_dir: APP_DIR)
80
+ assert_includes result[:html], "<h1>Default Title</h1>"
81
+ end
82
+
83
+ def test_render_unmatched_route_returns_empty
84
+ result = Funicular::SSR.render(path: "/no/such/path", source_dir: APP_DIR)
85
+ assert_equal "", result[:html]
86
+ assert_nil result[:component]
87
+ end
88
+
89
+ def test_route_params_become_props
90
+ # Renders without raising; :id from the path is passed as a prop.
91
+ result = Funicular::SSR.render(path: "/greet/42", source_dir: APP_DIR)
92
+ assert_includes result[:html], "greeting"
93
+ end
94
+ end
@@ -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"