funicular 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +486 -1
  3. data/demo/local_notes.html +207 -0
  4. data/demo/test_chartjs.html +9 -9
  5. data/demo/test_component.html +8 -8
  6. data/demo/test_error_boundary.html +44 -41
  7. data/demo/test_router.html +48 -48
  8. data/demo/tic-tac-toe.html +25 -25
  9. data/docs/architecture.md +227 -12
  10. data/docs/local_database.md +1035 -0
  11. data/lib/funicular/assets/funicular.rb +14 -0
  12. data/lib/funicular/configuration.rb +65 -0
  13. data/lib/funicular/epoch_header.rb +69 -0
  14. data/lib/funicular/epoch_stamping.rb +66 -0
  15. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  16. data/lib/funicular/railtie.rb +30 -0
  17. data/lib/funicular/schema.rb +45 -12
  18. data/lib/funicular/session_epoch.rb +110 -0
  19. data/lib/funicular/ssr/runtime.rb +58 -12
  20. data/lib/funicular/ssr.rb +25 -0
  21. data/lib/funicular/testing/node_runner.mjs +19 -0
  22. data/lib/funicular/testing.rb +47 -0
  23. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  24. data/lib/funicular/vendor/mrbc/mrbc.js +655 -574
  25. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby/debug/picoruby.js +800 -530
  28. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  29. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  30. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  31. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  32. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -6909
  33. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  34. data/lib/funicular/version.rb +1 -1
  35. data/lib/funicular.rb +1 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
  37. data/lib/tasks/funicular.rake +10 -2
  38. data/minitest/callback_error_visibility_test.rb +48 -0
  39. data/minitest/configuration_test.rb +78 -0
  40. data/minitest/dsl_test.rb +264 -0
  41. data/minitest/epoch_header_test.rb +149 -0
  42. data/minitest/epoch_stamping_test.rb +225 -0
  43. data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
  44. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  45. data/minitest/form_for_test.rb +2 -2
  46. data/minitest/hydration_test.rb +2 -2
  47. data/minitest/navigation_guard_test.rb +65 -0
  48. data/minitest/picoruby_helper_test.rb +236 -0
  49. data/minitest/schema_test.rb +47 -0
  50. data/minitest/session_epoch_test.rb +122 -0
  51. data/minitest/sig_tags_test.rb +30 -0
  52. data/minitest/ssr_database_test.rb +78 -0
  53. data/minitest/ssr_reload_test.rb +106 -0
  54. data/minitest/ssr_test.rb +41 -0
  55. data/minitest/testing_ensure_compiled_test.rb +52 -0
  56. data/minitest/validations_test.rb +35 -5
  57. data/minitest/view_context_test.rb +15 -15
  58. data/mrbgem.rake +2 -0
  59. data/mrblib/0_tags.rb +62 -0
  60. data/mrblib/cable.rb +1 -1
  61. data/mrblib/component.rb +226 -24
  62. data/mrblib/db.rb +3116 -0
  63. data/mrblib/error_boundary.rb +25 -19
  64. data/mrblib/file_upload.rb +17 -7
  65. data/mrblib/form_builder.rb +10 -10
  66. data/mrblib/funicular.rb +136 -17
  67. data/mrblib/http.rb +84 -107
  68. data/mrblib/model.rb +1178 -23
  69. data/mrblib/relation.rb +342 -0
  70. data/mrblib/router.rb +45 -4
  71. data/mrblib/styles.rb +122 -12
  72. data/mrblib/view_context.rb +3 -32
  73. data/sig/component.rbs +25 -4
  74. data/sig/db.rbs +328 -0
  75. data/sig/error_boundary.rbs +4 -4
  76. data/sig/funicular.rbs +5 -0
  77. data/sig/http.rbs +8 -21
  78. data/sig/model.rbs +101 -7
  79. data/sig/relation.rbs +44 -0
  80. data/sig/router.rbs +1 -0
  81. data/sig/styles.rbs +19 -5
  82. data/sig/tags.rbs +54 -0
  83. data/sig/view_context.rbs +47 -34
  84. metadata +23 -2
  85. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "funicular/epoch_stamping"
5
+ require "funicular/epoch_header"
6
+
7
+ # Exercises Funicular::EpochStamping (the controller mixin the Railtie
8
+ # installs) and its interplay with the EpochHeader middleware. The
9
+ # around_action stamps the epoch before the action AND re-stamps in
10
+ # its ensure with the post-action identity: a login/logout inside the
11
+ # action rotates on its own response, and a raising action still gets
12
+ # a truthful header on the exception page.
13
+ class EpochStampingTest < Minitest::Test
14
+ class FakeRequest
15
+ attr_reader :env, :session
16
+
17
+ def initialize(env = {}, session = nil)
18
+ @env = env
19
+ @session = session
20
+ end
21
+ end
22
+
23
+ class FakeControllerBase
24
+ def self.around_action(name)
25
+ (@around_actions ||= []) << name
26
+ end
27
+
28
+ def self.around_actions
29
+ @around_actions || []
30
+ end
31
+
32
+ attr_reader :request, :session
33
+
34
+ def initialize(session, env = {})
35
+ @request = FakeRequest.new(env, session)
36
+ @session = session
37
+ end
38
+
39
+ # A minimal filter chain: the action block runs inside every
40
+ # registered around_action, exceptions propagating like Rails'.
41
+ def process(&action)
42
+ chain = self.class.around_actions.reverse.inject(action) do |inner, name|
43
+ -> { send(name) { inner.call } }
44
+ end
45
+ chain.call
46
+ end
47
+ end
48
+
49
+ class StampedController < FakeControllerBase
50
+ include Funicular::EpochStamping
51
+
52
+ attr_accessor :current_user_key
53
+ end
54
+
55
+ # What request.session looks like in a session-less Rails API app:
56
+ # enabled? says no, every read and write raises.
57
+ class DisabledSession
58
+ def enabled?
59
+ false
60
+ end
61
+
62
+ def [](_key)
63
+ raise "sessions are disabled in this application"
64
+ end
65
+
66
+ def []=(_key, _value)
67
+ raise "sessions are disabled in this application"
68
+ end
69
+ end
70
+
71
+ def setup
72
+ @config = Funicular::Configuration.new
73
+ @config.local_database = true
74
+ @config.user_key = ->(controller) { controller.current_user_key }
75
+ Funicular.instance_variable_set(:@configuration, @config)
76
+ @session = {}
77
+ end
78
+
79
+ def teardown
80
+ Funicular.instance_variable_set(:@configuration, nil)
81
+ end
82
+
83
+ def session_epoch
84
+ entry = @session["funicular_epochs"]
85
+ entry ? entry["funicular"]["epoch"] : nil
86
+ end
87
+
88
+ # Runs one fake request: the controller starts as initial_key, the
89
+ # action block may mutate it (login/logout), the env epoch is what
90
+ # the middleware would write.
91
+ def run_request(initial_key, env = {}, &action)
92
+ controller = StampedController.new(@session, env)
93
+ controller.current_user_key = initial_key
94
+ controller.process { action ? action.call(controller) : nil }
95
+ env["funicular.epoch"]
96
+ end
97
+
98
+ def test_including_registers_the_around_action
99
+ assert_includes StampedController.around_actions, :stamp_funicular_epoch
100
+ end
101
+
102
+ # An application may route an action named "session" (a schema
103
+ # endpoint, say): that action SHADOWS the controller's session
104
+ # accessor. The concern must read request.session and never notice.
105
+ # (Not a StampedController subclass: the fake around_action registry
106
+ # is a class ivar and does not inherit.)
107
+ class SessionShadowedController < FakeControllerBase
108
+ include Funicular::EpochStamping
109
+
110
+ attr_accessor :current_user_key
111
+
112
+ def session
113
+ raise "the session action was invoked instead of the accessor"
114
+ end
115
+ end
116
+
117
+ def test_the_stamp_never_calls_the_controller_session_accessor
118
+ env = {}
119
+ controller = SessionShadowedController.new(@session, env)
120
+ controller.current_user_key = "u1"
121
+ ran = false
122
+ controller.process { ran = true }
123
+ assert ran
124
+ refute_nil env["funicular.epoch"]
125
+ assert_equal session_epoch, env["funicular.epoch"]
126
+ end
127
+
128
+ def test_disabled_local_database_does_not_touch_the_session_or_resolver
129
+ @config.local_database = false
130
+ @config.user_key = ->(_controller) { flunk "resolver was called" }
131
+ ran = false
132
+ session = {}
133
+ controller = StampedController.new(session)
134
+ controller.current_user_key = "u1"
135
+ controller.process { ran = true }
136
+ assert ran
137
+ assert_empty session
138
+ assert_nil controller.request.env["funicular.epoch"]
139
+ end
140
+
141
+ def test_the_epoch_lands_in_the_request_env
142
+ epoch = run_request("u1")
143
+ refute_nil epoch
144
+ refute_empty epoch
145
+ # Same user, same session: stable across requests.
146
+ assert_equal epoch, run_request("u1")
147
+ end
148
+
149
+ def test_the_env_value_matches_the_session_entry
150
+ epoch = run_request("u1")
151
+ assert_equal epoch, session_epoch
152
+ end
153
+
154
+ def test_a_login_inside_the_action_rotates_on_its_own_response
155
+ # The page was rendered signed-out; the LOGIN action itself flips
156
+ # the identity mid-request. Its own response must already carry
157
+ # the rotated epoch -- with only a pre-action stamp, an old page
158
+ # would apply the login response as an epoch match and the
159
+ # rotation would be a request late.
160
+ anonymous = run_request(nil)
161
+ login_epoch = run_request(nil) do |controller|
162
+ controller.current_user_key = "u1"
163
+ end
164
+ refute_equal anonymous, login_epoch
165
+ assert_equal session_epoch, login_epoch
166
+ # And it holds on the user's next request.
167
+ assert_equal login_epoch, run_request("u1")
168
+ end
169
+
170
+ def test_a_logout_inside_the_action_rotates_on_its_own_response
171
+ signed_in = run_request("u1")
172
+ logout_epoch = run_request("u1") do |controller|
173
+ controller.current_user_key = nil
174
+ end
175
+ refute_equal signed_in, logout_epoch
176
+ assert_equal session_epoch, logout_epoch
177
+ end
178
+
179
+ def test_a_disabled_session_skips_stamping_but_runs_the_action
180
+ # Session-less Rails API apps (no session middleware) must keep
181
+ # working with Funicular merely loaded: the epoch feature stays
182
+ # off instead of raising on every action.
183
+ ran = false
184
+ controller = StampedController.new(DisabledSession.new)
185
+ controller.current_user_key = "u1"
186
+ controller.process { ran = true }
187
+ assert ran
188
+ assert_nil controller.request.env["funicular.epoch"]
189
+ end
190
+
191
+ def test_an_enabled_session_object_still_stamps
192
+ enabled = Hash.new
193
+ def enabled.enabled?
194
+ true
195
+ end
196
+ controller = StampedController.new(enabled)
197
+ controller.current_user_key = "u1"
198
+ controller.process { nil }
199
+ refute_nil controller.request.env["funicular.epoch"]
200
+ end
201
+
202
+ def test_a_raising_action_still_produces_a_stamped_response
203
+ # The exception renderer builds a FRESH response none of the
204
+ # controller's headers survive into; the ensure re-stamp plus the
205
+ # middleware cover it -- with the POST-transition identity even
206
+ # when the action logged the user in before dying.
207
+ inner = lambda do |env|
208
+ controller = StampedController.new(@session, env)
209
+ controller.current_user_key = nil
210
+ begin
211
+ controller.process do
212
+ controller.current_user_key = "u1"
213
+ raise "boom in the action"
214
+ end
215
+ rescue RuntimeError
216
+ [500, {}, ["error page"]]
217
+ end
218
+ end
219
+ status, headers, = Funicular::EpochHeader.new(inner).call({})
220
+ assert_equal 500, status
221
+ assert_equal session_epoch, headers["x-funicular-epoch"]
222
+ assert_equal '["v1","funicular","user","u1"]',
223
+ @session["funicular_epochs"]["funicular"]["identity"]
224
+ end
225
+ end
@@ -3,12 +3,12 @@ class GreetingComponent < Funicular::Component
3
3
  { title: "Default Title", items: [] }
4
4
  end
5
5
 
6
- def render(h)
7
- h.div(class: "greeting") do |hh|
8
- hh.h1 { state[:title] }
9
- hh.ul do |hhh|
6
+ def render
7
+ div(class: "greeting") do
8
+ h1 { state[:title] }
9
+ ul do
10
10
  state[:items].each do |item|
11
- hhh.li(key: item["id"]) { item["name"] }
11
+ li(key: item["id"]) { item["name"] }
12
12
  end
13
13
  end
14
14
  end
@@ -0,0 +1,15 @@
1
+ # Reads both props and state so SSR.render_component tests can assert
2
+ # each channel is wired through. Not routed on purpose: rendering it
3
+ # is only possible through the single-component entry point.
4
+ class ProbeComponent < Funicular::Component
5
+ def initialize_state
6
+ { note: "default note" }
7
+ end
8
+
9
+ def render
10
+ div(class: "probe") do
11
+ h2 { props[:label].to_s }
12
+ p { state[:note] }
13
+ end
14
+ end
15
+ end
@@ -19,8 +19,8 @@ class FormForTest < Minitest::Test
19
19
  @submitted = data
20
20
  end
21
21
 
22
- def render(h)
23
- h.form_for(:comment, on_submit: :handle_submit) do |f|
22
+ def render
23
+ form_for(:comment, on_submit: :handle_submit) do |f|
24
24
  f.textarea(:body)
25
25
  f.submit("Post")
26
26
  end
@@ -25,8 +25,8 @@ class HydrationMatchTest < Minitest::Test
25
25
  # invoked here (we feed vnodes directly); it only satisfies the abstract API.
26
26
  def probe
27
27
  klass = Class.new(Funicular::Component) do
28
- def render(h)
29
- h.div { "x" }
28
+ def render
29
+ div { "x" }
30
30
  end
31
31
 
32
32
  def match?(vnode, dom)
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ Funicular::SSR::Runtime.load_framework!
6
+
7
+ # Router-side navigation guard: a component may veto navigation with a
8
+ # confirmation message (mrblib logic exercised under CRuby; the browser
9
+ # integration is covered by test/navigation_guard_test.rb and the
10
+ # beforeunload listener).
11
+ class NavigationGuardTest < Minitest::Test
12
+ class FreeComponent
13
+ def navigation_guard
14
+ nil
15
+ end
16
+ end
17
+
18
+ class GuardedComponent
19
+ def navigation_guard
20
+ "Unsaved changes will be lost. Leave?"
21
+ end
22
+ end
23
+
24
+ def setup
25
+ @router = Funicular::Router.new(nil)
26
+ end
27
+
28
+ def teardown
29
+ Funicular.confirm_handler = nil
30
+ end
31
+
32
+ def test_leaving_is_allowed_without_a_current_component
33
+ assert_equal true, @router.leave_allowed?
34
+ end
35
+
36
+ def test_leaving_is_allowed_when_the_guard_returns_nil
37
+ @router.instance_variable_set(:@current_component, FreeComponent.new)
38
+ assert_equal true, @router.leave_allowed?
39
+ end
40
+
41
+ def test_guard_message_is_prompted_and_denial_blocks_leaving
42
+ @router.instance_variable_set(:@current_component, GuardedComponent.new)
43
+ asked = []
44
+ Funicular.confirm_handler = ->(message) { asked << message; false }
45
+
46
+ assert_equal false, @router.leave_allowed?
47
+ assert_equal [ "Unsaved changes will be lost. Leave?" ], asked
48
+ end
49
+
50
+ def test_confirmation_allows_leaving
51
+ @router.instance_variable_set(:@current_component, GuardedComponent.new)
52
+ Funicular.confirm_handler = ->(_message) { true }
53
+
54
+ assert_equal true, @router.leave_allowed?
55
+ end
56
+
57
+ def test_confirm_defaults_to_true_on_the_server
58
+ # SSR must never block: without a handler, server mode allows leaving.
59
+ assert_equal true, Funicular.confirm("anything")
60
+ end
61
+
62
+ def test_component_guard_defaults_to_nil
63
+ assert_nil Funicular::Component.new.navigation_guard
64
+ end
65
+ end
@@ -63,6 +63,242 @@ class PicorubyHelperTest < Minitest::Test
63
63
  assert_includes html, "defer"
64
64
  end
65
65
 
66
+ # --- page metadata (docs decisions 12/13) -----------------------------
67
+
68
+ def test_disabled_include_tag_omits_all_local_database_metadata
69
+ html = @view.picoruby_include_tag(source: :local_dist, base_styles: false)
70
+ refute_includes html, "data-funicular-local-database"
71
+ refute_includes html, "data-funicular-application-id"
72
+ refute_includes html, "data-funicular-user-key"
73
+ refute_includes html, "data-funicular-anonymous-only"
74
+ refute_includes html, "data-funicular-epoch"
75
+ end
76
+
77
+ def test_disabled_include_tag_does_not_call_the_identity_resolver_or_session
78
+ @config.user_key = ->(_controller) { flunk "resolver was called" }
79
+ session = DisabledSession.new
80
+ view = metadata_view(user_key: "u1", session: session)
81
+ html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
82
+ refute_includes html, "data-funicular-local-database"
83
+ end
84
+
85
+ def test_include_tag_embeds_anonymous_only
86
+ @config.local_database = true
87
+ @config.anonymous_only = true
88
+ html = @view.picoruby_include_tag(source: :local_dist, base_styles: false)
89
+ assert_includes html, 'data-funicular-local-database="true"'
90
+ assert_includes html, 'data-funicular-application-id="funicular"'
91
+ assert_includes html, 'data-funicular-anonymous-only="true"'
92
+ end
93
+
94
+ def test_enabled_include_tag_requires_an_identity_declaration
95
+ @config.local_database = true
96
+ error = assert_raises(ArgumentError) do
97
+ @view.picoruby_include_tag(source: :local_dist, base_styles: false)
98
+ end
99
+ assert_includes error.message, "user_key"
100
+ end
101
+
102
+ # A harness with the controller/request surface a real view has. The
103
+ # helper reads the session through request.session (the view's own
104
+ # session helper delegates to the controller, where an action named
105
+ # "session" shadows it).
106
+ class MetadataHarness < Harness
107
+ attr_accessor :controller, :request
108
+ end
109
+
110
+ FakeController = Struct.new(:current_user_key)
111
+ FakeViewRequest = Struct.new(:session)
112
+
113
+ def metadata_view(user_key: nil, session: {})
114
+ view = MetadataHarness.new
115
+ view.controller = FakeController.new(user_key)
116
+ view.request = FakeViewRequest.new(session)
117
+ view
118
+ end
119
+
120
+ # A view rendered by a controller that routes an action named
121
+ # "session": the view's session delegate would invoke that action.
122
+ # The helper must go through request.session and never notice.
123
+ class SessionShadowedHarness < MetadataHarness
124
+ def session
125
+ raise "the session delegate was invoked instead of request.session"
126
+ end
127
+ end
128
+
129
+ def test_the_helper_never_calls_the_view_session_delegate
130
+ @config.local_database = true
131
+ @config.user_key = ->(controller) { controller.current_user_key }
132
+ view = SessionShadowedHarness.new
133
+ view.controller = FakeController.new("u1")
134
+ view.request = FakeViewRequest.new({})
135
+ html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
136
+ assert_includes html, "data-funicular-epoch"
137
+ end
138
+
139
+ def test_include_tag_embeds_user_key_and_epoch
140
+ @config.local_database = true
141
+ @config.user_key = ->(controller) { controller.current_user_key }
142
+ session = {}
143
+ view = metadata_view(user_key: "u1", session: session)
144
+ html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
145
+ assert_includes html, 'data-funicular-user-key="u1"'
146
+ assert_includes html, 'data-funicular-user-key-configured="true"'
147
+ # The epoch on the page is THE session entry the response header
148
+ # stamps: page and responses cannot disagree at render time.
149
+ epoch = session["funicular_epochs"]["funicular"]["epoch"]
150
+ assert_includes html, %(data-funicular-epoch="#{epoch}")
151
+ end
152
+
153
+ # What request.session looks like in a session-less Rails API app.
154
+ class DisabledSession
155
+ def enabled?
156
+ false
157
+ end
158
+
159
+ def [](_key)
160
+ raise "sessions are disabled in this application"
161
+ end
162
+
163
+ def []=(_key, _value)
164
+ raise "sessions are disabled in this application"
165
+ end
166
+ end
167
+
168
+ def test_a_disabled_session_skips_the_epoch_without_breaking
169
+ @config.local_database = true
170
+ @config.user_key = ->(controller) { controller.current_user_key }
171
+ view = metadata_view(user_key: "u1", session: DisabledSession.new)
172
+ html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
173
+ refute_includes html, "data-funicular-epoch"
174
+ assert_includes html, 'data-funicular-user-key="u1"'
175
+ end
176
+
177
+ def test_the_resolver_feeds_attribute_and_epoch_from_one_evaluation
178
+ # A racy resolver (current_user changing between two calls) must
179
+ # not embed user A's namespace while stamping user B's identity
180
+ # into the epoch entry: the helper evaluates it exactly once.
181
+ @config.local_database = true
182
+ calls = 0
183
+ @config.user_key = lambda do |_controller|
184
+ calls += 1
185
+ "u#{calls}"
186
+ end
187
+ session = {}
188
+ view = metadata_view(user_key: "ignored", session: session)
189
+ html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
190
+ assert_equal 1, calls
191
+ assert_includes html, 'data-funicular-user-key="u1"'
192
+ assert_equal '["v1","funicular","user","u1"]',
193
+ session["funicular_epochs"]["funicular"]["identity"]
194
+ end
195
+
196
+ def test_signed_out_omits_the_user_key_attribute
197
+ @config.local_database = true
198
+ @config.user_key = ->(controller) { controller.current_user_key }
199
+ view = metadata_view(user_key: nil)
200
+ html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
201
+ refute_includes html, "data-funicular-user-key="
202
+ assert_includes html, 'data-funicular-user-key-configured="true"'
203
+ end
204
+
205
+ def test_metadata_values_are_html_escaped
206
+ @config.local_database = true
207
+ @config.application_id = %q{"><script>alert(1)</script>}
208
+ @config.user_key = ->(controller) { controller.current_user_key }
209
+ view = metadata_view(user_key: %q{"><img src=x>})
210
+ html = view.picoruby_include_tag(source: :local_dist, base_styles: false)
211
+ refute_includes html, "<script>alert(1)</script>"
212
+ refute_includes html, "<img src=x>"
213
+ assert_includes html, "&quot;&gt;"
214
+ end
215
+
216
+ def test_caller_data_attributes_survive_alongside_the_metadata
217
+ @config.local_database = true
218
+ @config.anonymous_only = true
219
+ html = @view.picoruby_include_tag(source: :local_dist, base_styles: false,
220
+ data: { turbo_track: "reload" })
221
+ assert_includes html, 'data-turbo-track="reload"'
222
+ assert_includes html, 'data-funicular-application-id="funicular"'
223
+ end
224
+
225
+ def test_caller_data_cannot_override_the_framework_metadata
226
+ # A data: override of the epoch or the user key would make the
227
+ # page disagree with its own responses (instant terminal) or boot
228
+ # the wrong namespace: the framework's values win, in every key
229
+ # spelling that would render as the same HTML attribute.
230
+ @config.local_database = true
231
+ @config.user_key = ->(controller) { controller.current_user_key }
232
+ session = {}
233
+ view = metadata_view(user_key: "u1", session: session)
234
+ html = view.picoruby_include_tag(
235
+ source: :local_dist, base_styles: false,
236
+ data: { funicular_epoch: "evil-symbol",
237
+ "funicular_user_key" => "evil-string",
238
+ "funicular-application-id" => "evil-dashed",
239
+ turbo_track: "reload" })
240
+ refute_includes html, "evil-symbol"
241
+ refute_includes html, "evil-string"
242
+ refute_includes html, "evil-dashed"
243
+ assert_includes html, 'data-funicular-user-key="u1"'
244
+ assert_includes html, 'data-funicular-application-id="funicular"'
245
+ epoch = session["funicular_epochs"]["funicular"]["epoch"]
246
+ assert_includes html, %(data-funicular-epoch="#{epoch}")
247
+ # The caller's unrelated attribute still rides along, and no
248
+ # attribute is emitted twice.
249
+ assert_includes html, 'data-turbo-track="reload"'
250
+ assert_equal 1, html.scan("data-funicular-epoch=").size
251
+ assert_equal 1, html.scan("data-funicular-user-key=").size
252
+ assert_equal 1, html.scan("data-funicular-application-id=").size
253
+ end
254
+
255
+ def test_caller_data_cannot_enable_the_disabled_local_database
256
+ html = @view.picoruby_include_tag(
257
+ source: :local_dist, base_styles: false,
258
+ data: { funicular_local_database: "true",
259
+ "funicular-application-id" => "injected",
260
+ funicular_anonymous_only: "true" })
261
+ refute_includes html, "data-funicular-local-database"
262
+ refute_includes html, "data-funicular-application-id"
263
+ refute_includes html, "data-funicular-anonymous-only"
264
+ end
265
+
266
+ def test_top_level_options_cannot_override_the_framework_metadata
267
+ # The contract values can bypass the data: hash entirely and arrive
268
+ # as top-level options, in any spelling the tag builder accepts.
269
+ @config.local_database = true
270
+ @config.user_key = ->(controller) { controller.current_user_key }
271
+ session = {}
272
+ view = metadata_view(user_key: "u1", session: session)
273
+ html = view.picoruby_include_tag(
274
+ source: :local_dist, base_styles: false,
275
+ **{ "data-funicular-epoch" => "evil-dashed",
276
+ :"data-funicular-user-key" => "evil-symbol",
277
+ data_funicular_application_id: "evil-underscored",
278
+ "data-turbo-track" => "reload" })
279
+ refute_includes html, "evil-dashed"
280
+ refute_includes html, "evil-symbol"
281
+ refute_includes html, "evil-underscored"
282
+ assert_includes html, 'data-funicular-user-key="u1"'
283
+ assert_includes html, 'data-funicular-application-id="funicular"'
284
+ epoch = session["funicular_epochs"]["funicular"]["epoch"]
285
+ assert_includes html, %(data-funicular-epoch="#{epoch}")
286
+ # Unrelated top-level data attributes are the caller's business.
287
+ assert_includes html, 'data-turbo-track="reload"'
288
+ assert_equal 1, html.scan("data-funicular-epoch=").size
289
+ assert_equal 1, html.scan("data-funicular-user-key=").size
290
+ assert_equal 1, html.scan("data-funicular-application-id=").size
291
+ end
292
+
293
+ def test_top_level_options_cannot_enable_the_disabled_local_database
294
+ html = @view.picoruby_include_tag(
295
+ source: :local_dist, base_styles: false,
296
+ **{ "data-funicular-local-database" => "true",
297
+ data_funicular_application_id: "injected" })
298
+ refute_includes html, "data-funicular-local-database"
299
+ refute_includes html, "injected"
300
+ end
301
+
66
302
  def test_cdn_source_uses_versioned_jsdelivr_url
67
303
  @config.cdn_version = "1.2.3"
68
304
  html = @view.picoruby_include_tag(source: :cdn, base_styles: false)
@@ -88,6 +88,26 @@ class SchemaDerivationTest < Minitest::Test
88
88
  assert_equal({ "update" => { method: "PATCH", path: "/x/:id" } }, schema[:endpoints])
89
89
  end
90
90
 
91
+ def test_build_skips_validators_for_readonly_attributes
92
+ schema = Funicular::Schema.build(
93
+ Account,
94
+ attributes: {
95
+ # name has a presence validator, but a readonly (server-managed)
96
+ # attribute is never submitted by the client, so its validators
97
+ # must not be inlined at all.
98
+ "name" => { type: "string", readonly: true }
99
+ }
100
+ )
101
+ assert_equal({ type: "string", readonly: true }, schema[:attributes]["name"])
102
+
103
+ # String keys behave the same.
104
+ schema = Funicular::Schema.build(
105
+ Account,
106
+ attributes: { "name" => { "type" => "string", "readonly" => true } }
107
+ )
108
+ refute schema[:attributes]["name"].key?(:validations)
109
+ end
110
+
91
111
  def test_length_range_is_serialized_as_min_and_max
92
112
  klass = Class.new do
93
113
  include ActiveModel::Validations
@@ -99,6 +119,33 @@ class SchemaDerivationTest < Minitest::Test
99
119
  assert_equal({ "minimum" => 2, "maximum" => 10 }, result["bio"]["length"])
100
120
  end
101
121
 
122
+ def test_bare_numericality_keeps_allow_nil
123
+ # numericality with no constraints serializes to a bare true; the
124
+ # allow_nil flag must upgrade it to a Hash instead of being dropped.
125
+ klass = Class.new do
126
+ include ActiveModel::Validations
127
+ def self.name = "Aged"
128
+ attr_accessor :age
129
+ validates :age, numericality: true, allow_nil: true
130
+ end
131
+ result = Funicular::Schema.validations_for(klass, ["age"])
132
+ assert_equal({ "allow_nil" => true }, result["age"]["numericality"])
133
+ end
134
+
135
+ def test_format_unescapes_ruby_hash_escape
136
+ # Ruby regexp literals escape "#" to suppress interpolation and the escape
137
+ # survives in Regexp#source; JS RegExp under the u flag rejects "\#" as an
138
+ # invalid identity escape (URI::MailTo::EMAIL_REGEXP is the wild example).
139
+ klass = Class.new do
140
+ include ActiveModel::Validations
141
+ def self.name = "Mailish"
142
+ attr_accessor :email
143
+ validates :email, format: { with: /\A[a-z.!\#$%&]+\z/ }
144
+ end
145
+ result = Funicular::Schema.validations_for(klass, ["email"])
146
+ assert_equal "^[a-z.!#$%&]+$", result["email"]["format"]["with"]
147
+ end
148
+
102
149
  def test_format_multiline_flag_is_translated
103
150
  klass = Class.new do
104
151
  include ActiveModel::Validations