ruact 0.0.2 → 0.0.4
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/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +88 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1779 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +100 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +344 -0
- data/lib/ruact/server_functions/codegen_v2.rb +212 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +190 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +118 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +313 -0
- data/lib/ruact/server_functions/query_source.rb +150 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +111 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +598 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +508 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
- metadata +91 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "ruact/server_functions"
|
|
5
|
+
require "ruact/server_functions/query_source"
|
|
6
|
+
|
|
7
|
+
module Ruact
|
|
8
|
+
module ServerFunctions
|
|
9
|
+
# Plain query classes — only `instance_method(...).parameters` (kwargs
|
|
10
|
+
# presence) and `.name` (collision origin) are read. Defined at module
|
|
11
|
+
# level so they are not example-group-leaky constants.
|
|
12
|
+
class CatalogQ
|
|
13
|
+
def categories; end
|
|
14
|
+
|
|
15
|
+
def search_users(term:, limit: 10); end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class PeopleQ
|
|
19
|
+
def search_users(term:); end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Story 9.5 — query introspection: the drawn route table (filtered to the
|
|
23
|
+
# generated query dispatch controllers) → v2 query entries. Pure: route set
|
|
24
|
+
# and the query-class resolver are injected, so the derivation is testable
|
|
25
|
+
# with no Rails boot and no controllers (a fake route set + plain query
|
|
26
|
+
# classes).
|
|
27
|
+
RSpec.describe QuerySource, :story_9_5 do
|
|
28
|
+
# Minimal route double exposing the surface QuerySource reads:
|
|
29
|
+
# `defaults[:controller]`, `defaults[:action]`, `path.spec.to_s`.
|
|
30
|
+
def query_route(controller, action, path)
|
|
31
|
+
spec = Object.new
|
|
32
|
+
spec.define_singleton_method(:to_s) { "#{path}(.:format)" }
|
|
33
|
+
path_obj = Object.new
|
|
34
|
+
path_obj.define_singleton_method(:spec) { spec }
|
|
35
|
+
route = Object.new
|
|
36
|
+
route.define_singleton_method(:defaults) { { controller: controller, action: action } }
|
|
37
|
+
route.define_singleton_method(:path) { path_obj }
|
|
38
|
+
route
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def route_set(routes)
|
|
42
|
+
rs = Object.new
|
|
43
|
+
rs.define_singleton_method(:routes) { routes }
|
|
44
|
+
rs
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
let(:prefix) { QuerySource::QUERY_CONTROLLER_PREFIX }
|
|
48
|
+
|
|
49
|
+
def resolver(map)
|
|
50
|
+
->(controller) { map[controller] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe ".collect" do
|
|
54
|
+
it "emits one query entry per mounted query route (route-truth)" do
|
|
55
|
+
rs = route_set([
|
|
56
|
+
query_route("#{prefix}catalog_q", "categories", "/q/categories"),
|
|
57
|
+
query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers")
|
|
58
|
+
])
|
|
59
|
+
entries = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ))
|
|
60
|
+
|
|
61
|
+
expect(entries.map { |e| e["js_identifier"] }).to eq(%w[categories searchUsers])
|
|
62
|
+
expect(entries).to all(include("kind" => "query", "http_method" => "GET", "segments" => []))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "derives jsId via NameBridge and carries the clean path" do
|
|
66
|
+
rs = route_set([query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers")])
|
|
67
|
+
entry = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ)).first
|
|
68
|
+
expect(entry["js_identifier"]).to eq("searchUsers")
|
|
69
|
+
expect(entry["path"]).to eq("/q/searchUsers")
|
|
70
|
+
expect(entry["controller"]).to eq("Ruact::ServerFunctions::CatalogQ")
|
|
71
|
+
expect(entry["action"]).to eq("search_users")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "flags accepts_params true when the method declares kwargs, false otherwise" do
|
|
75
|
+
rs = route_set([
|
|
76
|
+
query_route("#{prefix}catalog_q", "categories", "/q/categories"),
|
|
77
|
+
query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers")
|
|
78
|
+
])
|
|
79
|
+
entries = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ))
|
|
80
|
+
by_id = entries.to_h { |e| [e["js_identifier"], e] }
|
|
81
|
+
expect(by_id["categories"]["accepts_params"]).to be(false)
|
|
82
|
+
expect(by_id["searchUsers"]["accepts_params"]).to be(true)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "ignores non-query routes (controller not under the dispatch namespace)" do
|
|
86
|
+
rs = route_set([
|
|
87
|
+
query_route("posts", "create", "/posts"),
|
|
88
|
+
query_route("#{prefix}catalog_q", "categories", "/q/categories")
|
|
89
|
+
])
|
|
90
|
+
entries = described_class.collect(rs, query_class_for: resolver("#{prefix}catalog_q" => CatalogQ))
|
|
91
|
+
expect(entries.map { |e| e["js_identifier"] }).to eq(%w[categories])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "skips a query route whose class cannot be resolved (nil resolver result)" do
|
|
95
|
+
rs = route_set([query_route("#{prefix}gone_q", "categories", "/q/categories")])
|
|
96
|
+
expect(described_class.collect(rs, query_class_for: resolver({}))).to eq([])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "raises a query×query collision naming both origins" do
|
|
100
|
+
rs = route_set([
|
|
101
|
+
query_route("#{prefix}catalog_q", "search_users", "/q/searchUsers"),
|
|
102
|
+
query_route("#{prefix}people_q", "search_users", "/q/searchUsers")
|
|
103
|
+
])
|
|
104
|
+
map = { "#{prefix}catalog_q" => CatalogQ, "#{prefix}people_q" => PeopleQ }
|
|
105
|
+
expect { described_class.collect(rs, query_class_for: resolver(map)) }
|
|
106
|
+
.to raise_error(Ruact::ConfigurationError, /CatalogQ#search_users and .*PeopleQ#search_users/m)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Story 9.5 (Task 2) — the merged JS namespace (route entries + query
|
|
112
|
+
# entries share it). The route×query side is detected at the codegen
|
|
113
|
+
# combine point.
|
|
114
|
+
RSpec.describe ".detect_merged_namespace_collisions!", :story_9_5 do
|
|
115
|
+
def action_entry(js_id, controller, action)
|
|
116
|
+
{ "js_identifier" => js_id, "kind" => "action", "controller" => controller, "action" => action }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def query_entry(js_id, controller, action)
|
|
120
|
+
{ "js_identifier" => js_id, "kind" => "query", "controller" => controller, "action" => action }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "raises on a route×query collision naming both origins + the rename escape hatch" do
|
|
124
|
+
entries = [
|
|
125
|
+
action_entry("categories", "posts", "categories"),
|
|
126
|
+
query_entry("categories", "CatalogQuery", "categories")
|
|
127
|
+
]
|
|
128
|
+
expect { ServerFunctions.detect_merged_namespace_collisions!(entries) }
|
|
129
|
+
.to raise_error(Ruact::ConfigurationError,
|
|
130
|
+
/posts#categories and CatalogQuery#categories.*ruact_function_name/m)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "does not raise when the rename makes the identifiers distinct" do
|
|
134
|
+
entries = [
|
|
135
|
+
action_entry("listCategories", "posts", "categories"), # renamed via ruact_function_name
|
|
136
|
+
query_entry("categories", "CatalogQuery", "categories")
|
|
137
|
+
]
|
|
138
|
+
expect { ServerFunctions.detect_merged_namespace_collisions!(entries) }.not_to raise_error
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "action_controller"
|
|
7
|
+
|
|
8
|
+
# Spec_helper's rails_stub defines Rails; the railtie was not auto-required
|
|
9
|
+
# because Rails was not yet defined when ruact.rb evaluated `require_relative
|
|
10
|
+
# "ruact/railtie" if defined?(Rails)`. Load it explicitly (mirrors
|
|
11
|
+
# spec/ruact/railtie_spec.rb).
|
|
12
|
+
require "ruact/railtie"
|
|
13
|
+
require "ruact/controller"
|
|
14
|
+
require "ruact/server"
|
|
15
|
+
|
|
16
|
+
# Story 8.0a — Railtie.write_server_functions_snapshot! is the entry point
|
|
17
|
+
# wired into `config.to_prepare`. The full to_prepare boot lives in
|
|
18
|
+
# controller_request_spec.rb; here we exercise the class method directly with
|
|
19
|
+
# Rails.root pointed at a tmpdir, which is enough to validate the contract
|
|
20
|
+
# (Story 8.0a Task 2.6 — Railtie path resolution + write-if-changed).
|
|
21
|
+
module Ruact
|
|
22
|
+
module ServerFunctions
|
|
23
|
+
RSpec.describe "Ruact::Railtie.write_server_functions_snapshot!", :story_8_0a do
|
|
24
|
+
around do |example|
|
|
25
|
+
Dir.mktmpdir do |dir|
|
|
26
|
+
original_root = Rails.root
|
|
27
|
+
Rails.root = Pathname.new(dir)
|
|
28
|
+
@tmpdir = dir
|
|
29
|
+
example.run
|
|
30
|
+
ensure
|
|
31
|
+
Rails.root = original_root
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
let(:path) { File.join(@tmpdir, "tmp/cache/ruact/server-functions.json") }
|
|
36
|
+
|
|
37
|
+
it "writes the JSON to tmp/cache/ruact/server-functions.json (Story 8.0a)" do
|
|
38
|
+
result = Ruact::Railtie.write_server_functions_snapshot!
|
|
39
|
+
expect(result).to be(true)
|
|
40
|
+
expect(File).to exist(path)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "writes an empty `functions: []` array when both registries are empty " \
|
|
44
|
+
"(Story 8.0a — empty-registry contract)" do
|
|
45
|
+
Ruact::Railtie.write_server_functions_snapshot!
|
|
46
|
+
parsed = JSON.parse(File.read(path))
|
|
47
|
+
expect(parsed.fetch("functions")).to eq([])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "the file is short-circuited on a second call with an unchanged registry " \
|
|
51
|
+
"(Story 8.0a — pitfall #1)" do
|
|
52
|
+
Ruact::Railtie.write_server_functions_snapshot!
|
|
53
|
+
expect(Ruact::Railtie.write_server_functions_snapshot!).to be(false)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "rewrites the file after a registration is added (Story 8.0a)" do
|
|
57
|
+
Ruact::Railtie.write_server_functions_snapshot!
|
|
58
|
+
Ruact.action_registry.register(:demo_ping, kind: :action)
|
|
59
|
+
|
|
60
|
+
expect(Ruact::Railtie.write_server_functions_snapshot!).to be(true)
|
|
61
|
+
parsed = JSON.parse(File.read(path))
|
|
62
|
+
expect(parsed["functions"].map { |fn| fn["ruby_symbol"] }).to eq(["demo_ping"])
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
RSpec.describe "Ruact::ServerFunctions.write_v2_snapshot! (Story 9.3)", :story_9_3 do
|
|
67
|
+
# A real Ruact::Server host so RouteSource's default constant-resolving
|
|
68
|
+
# host predicate recognizes it; stub_const (not a literal class) keeps the
|
|
69
|
+
# file single-definition and the constant scoped to the example.
|
|
70
|
+
before do
|
|
71
|
+
stub_const("V2DemoPostsController", Class.new(ActionController::Base) { include Ruact::Server })
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
around do |example|
|
|
75
|
+
Dir.mktmpdir { |dir| @tmpdir = dir and example.run }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def route_set
|
|
79
|
+
rs = ActionDispatch::Routing::RouteSet.new
|
|
80
|
+
rs.draw { resources :v2_demo_posts, only: %i[create update destroy] }
|
|
81
|
+
rs
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def write!(logger: nil)
|
|
85
|
+
Ruact::ServerFunctions.write_v2_snapshot!(
|
|
86
|
+
route_set: route_set, root: Pathname.new(@tmpdir), logger: logger
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
let(:next_json) { File.join(@tmpdir, "tmp/cache/ruact/server-functions.next.json") }
|
|
91
|
+
let(:next_ts) { File.join(@tmpdir, "app/javascript/.ruact/server-functions.next.ts") }
|
|
92
|
+
let(:real_json) { File.join(@tmpdir, "tmp/cache/ruact/server-functions.json") }
|
|
93
|
+
|
|
94
|
+
it "writes the v2 bridge + TS to the PARALLEL .next target (not the real file)" do
|
|
95
|
+
entries = write!
|
|
96
|
+
|
|
97
|
+
expect(entries.map { |e| e["js_identifier"] })
|
|
98
|
+
.to match_array(%w[createV2DemoPost updateV2DemoPost destroyV2DemoPost])
|
|
99
|
+
expect(File).to exist(next_json)
|
|
100
|
+
expect(File).to exist(next_ts)
|
|
101
|
+
# AC5/AC6 — the v1 real bridge is NOT written by the v2 path.
|
|
102
|
+
expect(File).not_to exist(real_json)
|
|
103
|
+
expect(JSON.parse(File.read(next_json)).fetch("version")).to eq(2)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "renders _makeServerFunction calls targeting real routes into the .next TS" do
|
|
107
|
+
write!
|
|
108
|
+
ts = File.read(next_ts)
|
|
109
|
+
expect(ts).to include('import { _makeServerFunction } from "ruact/server-functions-runtime";')
|
|
110
|
+
expect(ts).to include('_makeServerFunction({ method: "POST", path: "/v2_demo_posts", segments: [] });')
|
|
111
|
+
expect(ts).to include('_makeServerFunction({ method: "PATCH", path: "/v2_demo_posts/:id", segments: ["id"] });')
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "is byte-stable across calls on an unchanged route table (no churn)" do
|
|
115
|
+
write!
|
|
116
|
+
first = File.read(next_ts)
|
|
117
|
+
write!
|
|
118
|
+
expect(File.read(next_ts)).to eq(first)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "logs the exposed function names (AC2 — transparency over silence)" do
|
|
122
|
+
logger = instance_double(Logger, info: nil)
|
|
123
|
+
write!(logger: logger)
|
|
124
|
+
expect(logger).to have_received(:info).with(/\[ruact\] codegen: exposing .*createV2DemoPost/)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
RSpec.describe "Ruact::ServerFunctions.write_v2_snapshot! — queries (Story 9.5)", :story_9_5 do
|
|
129
|
+
# `ruact_queries` + query dispatch live behind these requires (loaded
|
|
130
|
+
# explicitly here as the railtie would at boot).
|
|
131
|
+
require "ruact/routing"
|
|
132
|
+
require "ruact/query"
|
|
133
|
+
|
|
134
|
+
around do |example|
|
|
135
|
+
Dir.mktmpdir { |dir| @tmpdir = dir and example.run }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
before do
|
|
139
|
+
stub_const("V2QueryParentController", Class.new(ActionController::Base))
|
|
140
|
+
Ruact.configure { |c| c.query_parent_controller = "V2QueryParentController" }
|
|
141
|
+
stub_const("V2CatalogQuery", Class.new(Ruact::Query) do
|
|
142
|
+
def categories; end
|
|
143
|
+
def search(term:); end
|
|
144
|
+
end)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def route_set
|
|
148
|
+
route_set = ActionDispatch::Routing::RouteSet.new
|
|
149
|
+
route_set.draw { ruact_queries V2CatalogQuery }
|
|
150
|
+
route_set
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def write!(routes = route_set)
|
|
154
|
+
Ruact::ServerFunctions.write_v2_snapshot!(route_set: routes, root: Pathname.new(@tmpdir))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
let(:next_ts) { File.join(@tmpdir, "app/javascript/.ruact/server-functions.next.ts") }
|
|
158
|
+
|
|
159
|
+
it "emits query entries (route-truth) merged into the v2 snapshot" do
|
|
160
|
+
entries = write!
|
|
161
|
+
expect(entries.map { |e| e["js_identifier"] }).to match_array(%w[categories search])
|
|
162
|
+
expect(entries).to all(include("kind" => "query", "http_method" => "GET"))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "renders _makeQuery refs + the useQuery re-export into the .next TS" do
|
|
166
|
+
write!
|
|
167
|
+
ts = File.read(next_ts)
|
|
168
|
+
expect(ts).to include('import { _makeQuery } from "ruact/server-functions-runtime";')
|
|
169
|
+
expect(ts).to include('_makeQuery({ path: "/q/categories", kind: "query" });')
|
|
170
|
+
expect(ts).to include("export const categories: () => Promise<unknown> =")
|
|
171
|
+
expect(ts).to include("export const search: (params: Record<string, unknown>) => Promise<unknown> =")
|
|
172
|
+
expect(ts).to include('export { useQuery } from "ruact/server-functions-runtime";')
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it "raises a route×query collision when an action and a query share a js_identifier" do
|
|
176
|
+
stub_const("CategoriesController", Class.new(ActionController::Base) { include Ruact::Server })
|
|
177
|
+
stub_const("CollideQuery", Class.new(Ruact::Query) { def categories; end })
|
|
178
|
+
rs = ActionDispatch::Routing::RouteSet.new
|
|
179
|
+
rs.draw do
|
|
180
|
+
# POST /categories#create would derive js_identifier "createCategory" — to
|
|
181
|
+
# force a clash, use a custom collection route named to collide head-on.
|
|
182
|
+
post "categories", to: "categories#categories"
|
|
183
|
+
ruact_queries CollideQuery
|
|
184
|
+
end
|
|
185
|
+
# Rename the action's js_identifier to exactly "categories" so it collides
|
|
186
|
+
# with the query method.
|
|
187
|
+
CategoriesController.define_singleton_method(:__ruact_function_name_overrides) do
|
|
188
|
+
{ "categories" => "categories" }
|
|
189
|
+
end
|
|
190
|
+
expect { Ruact::ServerFunctions.write_v2_snapshot!(route_set: rs, root: Pathname.new(@tmpdir)) }
|
|
191
|
+
.to raise_error(Ruact::ConfigurationError, /both map to JS identifier "categories".*ruact_function_name/m)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
RSpec.describe "Ruact::Railtie registry-clear hook (Story 8.1)", :story_8_1 do
|
|
196
|
+
# The Railtie attaches a `before_class_unload` callback that clears both
|
|
197
|
+
# registries before Zeitwerk tears down constants — this prevents removed
|
|
198
|
+
# `ruact_action` declarations from lingering across reloads. The full
|
|
199
|
+
# Rails-app boot covering the controller class-body re-evaluation lives
|
|
200
|
+
# in `controller_request_spec.rb`; here we exercise the hook directly.
|
|
201
|
+
before do
|
|
202
|
+
Ruact.action_registry.clear!
|
|
203
|
+
Ruact.query_registry.clear!
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it "clears both registries when invoked" do
|
|
207
|
+
Ruact.action_registry.register(:foo, kind: :action)
|
|
208
|
+
Ruact.query_registry.register(:bar, kind: :query)
|
|
209
|
+
expect(Ruact.action_registry.size).to eq(1)
|
|
210
|
+
expect(Ruact.query_registry.size).to eq(1)
|
|
211
|
+
|
|
212
|
+
# Direct invocation of the cleanup that the reloader hook would run.
|
|
213
|
+
Ruact.action_registry.clear!
|
|
214
|
+
Ruact.query_registry.clear!
|
|
215
|
+
|
|
216
|
+
expect(Ruact.action_registry.size).to eq(0)
|
|
217
|
+
expect(Ruact.query_registry.size).to eq(0)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it "the snapshot write-if-changed guard skips a rewrite when controllers " \
|
|
221
|
+
"re-register the same symbols after a clear (Story 8.1 — pitfall #1 mitigation)" do
|
|
222
|
+
Dir.mktmpdir do |dir|
|
|
223
|
+
original_root = Rails.root
|
|
224
|
+
Rails.root = Pathname.new(dir)
|
|
225
|
+
|
|
226
|
+
Ruact.action_registry.register(:create_post, kind: :action)
|
|
227
|
+
Ruact::Railtie.write_server_functions_snapshot!
|
|
228
|
+
original_bytes = File.read(File.join(dir, "tmp/cache/ruact/server-functions.json"))
|
|
229
|
+
|
|
230
|
+
# Simulate a reload cycle: clear, then re-register the same symbol
|
|
231
|
+
# with a fresh class object (the same as what would happen when
|
|
232
|
+
# controller class bodies re-evaluate after Zeitwerk teardown).
|
|
233
|
+
Ruact.action_registry.clear!
|
|
234
|
+
Ruact.action_registry.register(:create_post, kind: :action)
|
|
235
|
+
|
|
236
|
+
expect(Ruact::Railtie.write_server_functions_snapshot!).to be(false)
|
|
237
|
+
expect(File.read(File.join(dir, "tmp/cache/ruact/server-functions.json"))).to eq(original_bytes)
|
|
238
|
+
ensure
|
|
239
|
+
Rails.root = original_root
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Story 8.1 Re-run-6/8 — force_load_controllers! now walks Rails::Engine
|
|
245
|
+
# subclasses so engine-owned `ruact_action` declarations populate the
|
|
246
|
+
# registry at boot (not on first request to the engine controller).
|
|
247
|
+
# The regression target: a mounted engine that declares its own controller
|
|
248
|
+
# with `ruact_action :engine_action` must be visible to the snapshot
|
|
249
|
+
# writer + endpoint dispatcher BEFORE any HTTP traffic.
|
|
250
|
+
RSpec.describe "Ruact::Railtie.force_load_controllers! engine scanning (Story 8.1)", :story_8_1 do
|
|
251
|
+
before do
|
|
252
|
+
Ruact.action_registry.clear!
|
|
253
|
+
Ruact.query_registry.clear!
|
|
254
|
+
|
|
255
|
+
# In a real Rails boot, `require_dependency` is added to `Object` by
|
|
256
|
+
# `ActiveSupport::Dependencies.hook!` before `config.to_prepare`
|
|
257
|
+
# fires. The minimal spec-env setup (rails_stub + action_controller
|
|
258
|
+
# core) does not invoke the hook, so we stub the call directly to
|
|
259
|
+
# delegate to plain `load(file)` — which is sufficient to exercise
|
|
260
|
+
# the engine-scanning branch without dragging the full dependencies
|
|
261
|
+
# subsystem into the suite.
|
|
262
|
+
allow(Ruact::Railtie).to receive(:force_load_dir).and_wrap_original do |_original, dir|
|
|
263
|
+
files = Dir.glob("#{dir}/**/*_controller.rb")
|
|
264
|
+
files.each { |file| load(file) }
|
|
265
|
+
files.length
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it "loads ruact_action declarations from a mounted Rails::Engine's app/controllers " \
|
|
270
|
+
"(re-run-6 #4 / re-run-8 #2 — engine-owned controllers must populate the registry at boot)" do
|
|
271
|
+
Dir.mktmpdir do |engine_dir|
|
|
272
|
+
# Build the engine's controller file on disk. The file's body
|
|
273
|
+
# declares a real `ruact_action` so populating the registry is
|
|
274
|
+
# observable (no mocks of the macro itself).
|
|
275
|
+
controllers_dir = File.join(engine_dir, "app/controllers")
|
|
276
|
+
FileUtils.mkdir_p(controllers_dir)
|
|
277
|
+
controller_path = File.join(controllers_dir, "engine_demo_controller.rb")
|
|
278
|
+
File.write(controller_path, <<~RUBY)
|
|
279
|
+
# frozen_string_literal: true
|
|
280
|
+
|
|
281
|
+
class EngineDemoController < ActionController::Base
|
|
282
|
+
include Ruact::Controller
|
|
283
|
+
|
|
284
|
+
ruact_action(:engine_only_action) { |_params| "from-engine" }
|
|
285
|
+
end
|
|
286
|
+
RUBY
|
|
287
|
+
|
|
288
|
+
# Build a real Rails::Engine subclass whose paths["app/controllers"]
|
|
289
|
+
# points at the on-disk controllers directory. `Engine#paths` is
|
|
290
|
+
# automatically populated by Rails.
|
|
291
|
+
fake_engine = Class.new(Rails::Engine) do
|
|
292
|
+
isolate_namespace Module.new
|
|
293
|
+
config.paths["app/controllers"] = controllers_dir
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Stub Rails::Engine.subclasses to return JUST our fake engine — the
|
|
297
|
+
# host app's own engine class is filtered out inside
|
|
298
|
+
# force_load_controllers! by an explicit `engine_class ==
|
|
299
|
+
# Rails.application.class` skip.
|
|
300
|
+
allow(Rails::Engine).to receive(:subclasses).and_return([fake_engine])
|
|
301
|
+
|
|
302
|
+
expect { Ruact::Railtie.force_load_controllers! }.not_to raise_error
|
|
303
|
+
expect(Ruact.action_registry.entries[:engine_only_action]).not_to be_nil
|
|
304
|
+
expect(Ruact.action_registry.entries[:engine_only_action].controller).to be(EngineDemoController)
|
|
305
|
+
end
|
|
306
|
+
ensure
|
|
307
|
+
# `EngineDemoController` is loaded via `require_dependency` against an
|
|
308
|
+
# absolute on-disk path; remove the constant so re-runs of the spec
|
|
309
|
+
# don't trip the macro's "method already defined" guard.
|
|
310
|
+
Object.send(:remove_const, :EngineDemoController) if defined?(EngineDemoController)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it "skips the host application's own Rails::Engine subclass " \
|
|
314
|
+
"(avoids double-loading app/controllers already covered by the Rails.application branch)" do
|
|
315
|
+
# Rails.application.class IS a Rails::Engine subclass; force_load_controllers!
|
|
316
|
+
# iterates the host app FIRST via the application branch, then skips it
|
|
317
|
+
# explicitly in the engine branch. Confirm that filtering happens.
|
|
318
|
+
host_class = Rails.application.class
|
|
319
|
+
allow(Rails::Engine).to receive(:subclasses).and_return([host_class])
|
|
320
|
+
|
|
321
|
+
# We expect ZERO additional load operations from the engine branch
|
|
322
|
+
# because the only subclass is the host app itself.
|
|
323
|
+
expect(Ruact::Railtie).not_to receive(:safe_engine_instance)
|
|
324
|
+
|
|
325
|
+
expect { Ruact::Railtie.force_load_controllers! }.not_to raise_error
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
it "swallows a misconfigured engine (engine_class.instance raising) " \
|
|
329
|
+
"via safe_engine_instance so a single broken engine cannot block boot" do
|
|
330
|
+
bad_engine = Class.new(Rails::Engine)
|
|
331
|
+
allow(bad_engine).to receive(:instance).and_raise(StandardError, "engine boot failed")
|
|
332
|
+
allow(Rails::Engine).to receive(:subclasses).and_return([bad_engine])
|
|
333
|
+
|
|
334
|
+
# Must not propagate; force_load_controllers! returns normally.
|
|
335
|
+
expect { Ruact::Railtie.force_load_controllers! }.not_to raise_error
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Story 8.3 — force_load_server_function_hosts! ALSO walks
|
|
340
|
+
# `app/server_actions/**/*.rb` so standalone modules register at boot
|
|
341
|
+
# alongside controller-hosted actions. Follows the Story 8.1 fake-engine
|
|
342
|
+
# pattern to bypass Rails.application's sticky root memoization.
|
|
343
|
+
RSpec.describe "Ruact::Railtie.force_load_server_function_hosts! " \
|
|
344
|
+
"app/server_actions/ scanning (Story 8.3)", :story_8_3 do
|
|
345
|
+
before do
|
|
346
|
+
Ruact.action_registry.clear!
|
|
347
|
+
Ruact.query_registry.clear!
|
|
348
|
+
|
|
349
|
+
# Same stub as the Story 8.1 engine-scanning describe — substitutes
|
|
350
|
+
# `require_dependency` (unavailable in the minimal spec env) with
|
|
351
|
+
# plain `load`. Accepts both `dir` (positional) and `glob:` (kwarg).
|
|
352
|
+
allow(Ruact::Railtie).to receive(:force_load_dir).and_wrap_original do |_original, dir, glob: "**/*_controller.rb"|
|
|
353
|
+
files = Dir.glob("#{dir}/#{glob}")
|
|
354
|
+
files.each { |file| load(file) }
|
|
355
|
+
files.length
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
it "loads ruact_action declarations from app/server_actions/ at boot " \
|
|
360
|
+
"(Pitfall #7 — standalone modules must register before the snapshot writer runs)" do
|
|
361
|
+
Dir.mktmpdir do |engine_dir|
|
|
362
|
+
server_actions_dir = File.join(engine_dir, "app/server_actions")
|
|
363
|
+
FileUtils.mkdir_p(server_actions_dir)
|
|
364
|
+
module_path = File.join(server_actions_dir, "standalone_railtie_demo.rb")
|
|
365
|
+
File.write(module_path, <<~RUBY)
|
|
366
|
+
# frozen_string_literal: true
|
|
367
|
+
|
|
368
|
+
module StandaloneRailtieDemo
|
|
369
|
+
extend Ruact::ServerAction
|
|
370
|
+
|
|
371
|
+
ruact_action(:standalone_railtie_demo) { |_p| "from-standalone" }
|
|
372
|
+
end
|
|
373
|
+
RUBY
|
|
374
|
+
|
|
375
|
+
fake_engine = Class.new(Rails::Engine) do
|
|
376
|
+
isolate_namespace Module.new
|
|
377
|
+
# Register the path explicitly so `server_actions_paths_for`
|
|
378
|
+
# finds it via the Rails paths enumerator.
|
|
379
|
+
config.paths.add "app/server_actions", with: server_actions_dir
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Stub Rails::Engine.subclasses to expose ONLY the fake engine —
|
|
383
|
+
# the host app's own controllers/server_actions are filtered out
|
|
384
|
+
# by the engine_class == Rails.application.class skip inside
|
|
385
|
+
# force_load_server_function_hosts!.
|
|
386
|
+
allow(Rails::Engine).to receive(:subclasses).and_return([fake_engine])
|
|
387
|
+
|
|
388
|
+
expect { Ruact::Railtie.force_load_server_function_hosts! }.not_to raise_error
|
|
389
|
+
|
|
390
|
+
entry = Ruact.action_registry.entries[:standalone_railtie_demo]
|
|
391
|
+
expect(entry).not_to be_nil
|
|
392
|
+
expect(entry.controller).to be(StandaloneRailtieDemo)
|
|
393
|
+
expect(entry.controller).to be_a(Module)
|
|
394
|
+
expect(entry.controller).not_to be_a(Class)
|
|
395
|
+
end
|
|
396
|
+
ensure
|
|
397
|
+
Object.send(:remove_const, :StandaloneRailtieDemo) if defined?(StandaloneRailtieDemo)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
it "silently no-ops when no engine has an app/server_actions/ directory " \
|
|
401
|
+
"(typical for apps that only use controller-hosted actions)" do
|
|
402
|
+
allow(Rails::Engine).to receive(:subclasses).and_return([])
|
|
403
|
+
expect { Ruact::Railtie.force_load_server_function_hosts! }.not_to raise_error
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
it "back-compat: the old `force_load_controllers!` name aliases to the new method" do
|
|
407
|
+
expect(Ruact::Railtie.method(:force_load_controllers!).original_name)
|
|
408
|
+
.to eq(:force_load_server_function_hosts!)
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "rake"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
# Story 8.0a — exercises `rake ruact:server_functions:generate` end-to-end:
|
|
9
|
+
# the task's body runs against a tmpdir as Rails.root, stubs the
|
|
10
|
+
# :environment dependency, and asserts that:
|
|
11
|
+
# 1. The JSON bridge is written to tmp/cache/ruact/.
|
|
12
|
+
# 2. The TypeScript module is written to app/javascript/.ruact/.
|
|
13
|
+
# 3. Re-running the task is idempotent (no rewrites on unchanged registry).
|
|
14
|
+
# 4. ConfigurationError surfaces with a non-zero exit and a `[ruact] error:` line.
|
|
15
|
+
module Ruact
|
|
16
|
+
module ServerFunctions
|
|
17
|
+
RSpec.describe "rake ruact:server_functions:generate", :story_8_0a do
|
|
18
|
+
around do |example|
|
|
19
|
+
Dir.mktmpdir do |dir|
|
|
20
|
+
original_root = Rails.root
|
|
21
|
+
Rails.root = Pathname.new(dir)
|
|
22
|
+
@tmpdir = dir
|
|
23
|
+
|
|
24
|
+
prev = Rake.application
|
|
25
|
+
Rake.application = Rake::Application.new
|
|
26
|
+
Rake.application.define_task(Rake::Task, :environment)
|
|
27
|
+
load File.expand_path("../../../lib/tasks/ruact.rake", __dir__)
|
|
28
|
+
|
|
29
|
+
example.run
|
|
30
|
+
ensure
|
|
31
|
+
Rake.application = prev
|
|
32
|
+
Rails.root = original_root
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
let(:json_path) { File.join(@tmpdir, "tmp/cache/ruact/server-functions.json") }
|
|
37
|
+
let(:ts_path) { File.join(@tmpdir, "app/javascript/.ruact/server-functions.ts") }
|
|
38
|
+
|
|
39
|
+
def invoke!
|
|
40
|
+
Rake::Task["ruact:server_functions:generate"].reenable
|
|
41
|
+
Rake::Task["ruact:server_functions:generate"].invoke
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "registers the task under the documented name (Story 8.0a)" do
|
|
45
|
+
expect(Rake.application.lookup("ruact:server_functions:generate")).not_to be_nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "writes both the JSON bridge and the TS module on first run (Story 8.0a)",
|
|
49
|
+
:aggregate_failures do
|
|
50
|
+
invoke!
|
|
51
|
+
expect(File).to exist(json_path)
|
|
52
|
+
expect(File).to exist(ts_path)
|
|
53
|
+
expect(File.read(ts_path)).to include("// AUTO-GENERATED by vite-plugin-ruact")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "is idempotent: a second run does not change file contents (Story 8.0a)" do
|
|
57
|
+
invoke!
|
|
58
|
+
before = File.read(ts_path)
|
|
59
|
+
invoke!
|
|
60
|
+
expect(File.read(ts_path)).to eq(before)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "produces the byte-identical TS module to Codegen.render (Story 8.0a AC7)" do
|
|
64
|
+
Ruact.action_registry.register(:demo_ping, kind: :action)
|
|
65
|
+
invoke!
|
|
66
|
+
|
|
67
|
+
snapshot = Snapshot.dump(Ruact.action_registry, Ruact.query_registry,
|
|
68
|
+
now: Time.parse(JSON.parse(File.read(json_path))["generated_at"]))
|
|
69
|
+
expect(File.read(ts_path)).to eq(Codegen.render(snapshot))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "exits 1 with a `[ruact] error:` line when the registry has an invalid symbol " \
|
|
73
|
+
"(Story 8.0a — rake error reporting)" do
|
|
74
|
+
# Inject a bad entry by bypassing the registry's validation (the
|
|
75
|
+
# naming-bridge raises on .register; we want to verify what happens
|
|
76
|
+
# when an invalid entry slipped through and the task re-validates).
|
|
77
|
+
allow(Snapshot).to receive(:generate!)
|
|
78
|
+
.and_raise(Ruact::ConfigurationError,
|
|
79
|
+
"ruact_action / ruact_query symbol :RECALCULATE must match /^[a-z_][a-z0-9_]*$/")
|
|
80
|
+
expect { invoke! }
|
|
81
|
+
.to raise_error(SystemExit)
|
|
82
|
+
.and output(/\[ruact\] error:.*RECALCULATE/).to_stderr
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|