ruact 0.0.5 → 0.0.6
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/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +67 -18
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/railtie.rb +56 -200
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +102 -0
- data/lib/ruact.rb +19 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +63 -326
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
- data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +55 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
|
@@ -0,0 +1,1835 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "stringio"
|
|
7
|
+
require "generators/ruact/scaffold/scaffold_generator"
|
|
8
|
+
|
|
9
|
+
# Story 10.1 — the `ruact:scaffold` generator. These specs run the REAL
|
|
10
|
+
# generator into a `Dir.mktmpdir` (mirroring the install_generator_spec
|
|
11
|
+
# "real generator invocation" precedent) and assert on the rendered template
|
|
12
|
+
# CONTENT — the v2 contract markers — not just file existence.
|
|
13
|
+
RSpec.describe Ruact::Generators::ScaffoldGenerator, :story_10_1 do # rubocop:disable RSpec/SpecFilePathFormat
|
|
14
|
+
let(:app_root) { Dir.mktmpdir("ruact_scaffold_spec") }
|
|
15
|
+
|
|
16
|
+
after { FileUtils.rm_rf(app_root) }
|
|
17
|
+
|
|
18
|
+
before do
|
|
19
|
+
FileUtils.mkdir_p(File.join(app_root, "config"))
|
|
20
|
+
File.write(File.join(app_root, "config/routes.rb"),
|
|
21
|
+
"Rails.application.routes.draw do\nend\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def silently
|
|
25
|
+
original = $stdout
|
|
26
|
+
$stdout = StringIO.new
|
|
27
|
+
yield
|
|
28
|
+
ensure
|
|
29
|
+
$stdout = original
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build(args, options = {})
|
|
33
|
+
described_class.new(args, options, destination_root: app_root)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Runs the generator's file-producing task methods in their declared order.
|
|
37
|
+
def run_scaffold(args = %w[Post title:string body:text published:boolean], options = {})
|
|
38
|
+
gen = build(args, options)
|
|
39
|
+
silently do
|
|
40
|
+
gen.create_controller
|
|
41
|
+
gen.add_resource_route
|
|
42
|
+
gen.create_queries
|
|
43
|
+
gen.add_query_route
|
|
44
|
+
gen.create_views
|
|
45
|
+
gen.create_components
|
|
46
|
+
gen.create_smoke_spec
|
|
47
|
+
end
|
|
48
|
+
gen
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def read(relative_path)
|
|
52
|
+
File.read(File.join(app_root, relative_path))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe "files produced (AC1)" do
|
|
56
|
+
before { run_scaffold }
|
|
57
|
+
|
|
58
|
+
it "creates every file of the skeleton", :aggregate_failures do
|
|
59
|
+
%w[
|
|
60
|
+
app/controllers/posts_controller.rb
|
|
61
|
+
config/routes.rb
|
|
62
|
+
app/javascript/components/PostList.tsx
|
|
63
|
+
app/javascript/components/PostForm.tsx
|
|
64
|
+
app/javascript/components/PostDeleteDialog.tsx
|
|
65
|
+
app/views/posts/index.html.erb
|
|
66
|
+
app/views/posts/show.html.erb
|
|
67
|
+
app/views/posts/new.html.erb
|
|
68
|
+
app/views/posts/edit.html.erb
|
|
69
|
+
spec/requests/posts_spec.rb
|
|
70
|
+
].each do |path|
|
|
71
|
+
expect(File).to exist(File.join(app_root, path)), "expected #{path} to be generated"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "injects `resources :posts` into config/routes.rb" do
|
|
76
|
+
expect(read("config/routes.rb")).to include("resources :posts")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe "controller v2 contract (AC3)" do
|
|
81
|
+
before { run_scaffold }
|
|
82
|
+
|
|
83
|
+
let(:controller) { read("app/controllers/posts_controller.rb") }
|
|
84
|
+
|
|
85
|
+
it "is a PLURAL resource controller class (Zeitwerk-correct)" do
|
|
86
|
+
expect(controller).to include("class PostsController < ApplicationController")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "includes Ruact::Server" do
|
|
90
|
+
expect(controller).to include("include Ruact::Server")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "does NOT include ActionController::Live (no Suspense in the skeleton)" do
|
|
94
|
+
expect(controller).not_to include("ActionController::Live")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "sets ivars on GET actions with no explicit ruact_render call", :aggregate_failures do
|
|
98
|
+
expect(controller).to include("@posts = Post.all")
|
|
99
|
+
# No real `ruact_render` CALL (the doc comment mentions it inside backticks).
|
|
100
|
+
expect(controller).not_to match(/^\s*ruact_render\b/)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "drives navigation server-side via redirect_to on create/update success", :aggregate_failures do
|
|
104
|
+
expect(controller).to include("redirect_to post")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "surfaces the FR98 keyed errors map on the failure path" do
|
|
108
|
+
expect(controller).to include("ruact_errors(post)")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "reads strong-params explicitly and coerces booleans" do
|
|
112
|
+
expect(controller).to include("ActiveModel::Type::Boolean.new.cast(params[:published])")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "defaults to the raw :id finder (RESTful, live)" do
|
|
116
|
+
expect(controller).to include("Post.find(params[:id])")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "keeps every FR96 SignedGlobalID surface COMMENTED (opt-in, not default)", :aggregate_failures do
|
|
120
|
+
expect(controller).to include("# @post = Ruact.locate_signed(params[:post_ref], for: :post_ref)")
|
|
121
|
+
expect(controller).to include("# @post_ref = Ruact.signed_global_id(post, for: :post_ref")
|
|
122
|
+
expect(controller).to include("# def publish")
|
|
123
|
+
# No LIVE signed-id surface leaks (every occurrence is on a comment line).
|
|
124
|
+
controller.each_line do |line|
|
|
125
|
+
if line.include?("Ruact.signed_global_id") || line.include?("Ruact.locate_signed")
|
|
126
|
+
expect(line).to match(/\A\s*#/)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
describe "ERB views render components from ivars, no as_json (AC1, AC2)" do
|
|
133
|
+
before { run_scaffold }
|
|
134
|
+
|
|
135
|
+
it "index serializes the ivar to rows and renders <PostList>", :aggregate_failures do
|
|
136
|
+
index = read("app/views/posts/index.html.erb")
|
|
137
|
+
expect(index).to include("<PostList posts={rows} />")
|
|
138
|
+
expect(index).to include("@posts.map")
|
|
139
|
+
# No real `.as_json` call (the doc comment mentions "no as_json").
|
|
140
|
+
expect(index).not_to match(/\.as_json/)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "new renders a bare <PostForm />" do
|
|
144
|
+
expect(read("app/views/posts/new.html.erb")).to include("<PostForm />")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it "edit renders <PostForm initial={...}> from the serialized record", :aggregate_failures do
|
|
148
|
+
edit = read("app/views/posts/edit.html.erb")
|
|
149
|
+
expect(edit).to include("<PostForm initial={initial} />")
|
|
150
|
+
expect(edit).not_to match(/\.as_json/)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "show is a plain Rails view reading @post" do
|
|
154
|
+
expect(read("app/views/posts/show.html.erb")).to include("@post")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
describe "typed .tsx components by default (AC1, AC7)" do
|
|
159
|
+
before { run_scaffold }
|
|
160
|
+
|
|
161
|
+
it "List: use client, FR100 contract, FR99 row type, delegates delete", :aggregate_failures do
|
|
162
|
+
list = read("app/javascript/components/PostList.tsx")
|
|
163
|
+
expect(list).to start_with(%("use client";))
|
|
164
|
+
expect(list).to include("export const __ruactContract")
|
|
165
|
+
expect(list).to include("type PostRow = {")
|
|
166
|
+
expect(list).to include('import { PostDeleteDialog } from "./PostDeleteDialog"')
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it "Form: imports only the action accessors, typed, contract-carrying", :aggregate_failures do
|
|
170
|
+
form = read("app/javascript/components/PostForm.tsx")
|
|
171
|
+
expect(form).to include('import { createPost, updatePost } from "@/.ruact/server-functions"')
|
|
172
|
+
expect(form).to include("export const __ruactContract")
|
|
173
|
+
expect(form).to include("type PostRow = {")
|
|
174
|
+
expect(form).not_to include("useQuery")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Story 14.5 — re-pointed at the --shadcn path: regenerate the default model
|
|
178
|
+
# under `--shadcn` (force-overwriting the agnostic `before` output) so the
|
|
179
|
+
# controlled shadcn AlertDialog is asserted.
|
|
180
|
+
it "DeleteDialog: a controlled AlertDialog (onConfirm-driven), contract-carrying", :aggregate_failures do
|
|
181
|
+
# Story 10.4 — the dialog no longer imports destroyPost (the List's
|
|
182
|
+
# RowActions owns the destroy call + provides onConfirm); the dialog is a
|
|
183
|
+
# controlled shadcn AlertDialog.
|
|
184
|
+
run_scaffold(%w[Post title:string body:text published:boolean], shadcn: true, force: true)
|
|
185
|
+
dialog = read("app/javascript/components/PostDeleteDialog.tsx")
|
|
186
|
+
expect(dialog).to include('from "@/components/ui/alert-dialog"')
|
|
187
|
+
expect(dialog).not_to include("import { destroyPost }")
|
|
188
|
+
expect(dialog).to include("export const __ruactContract")
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
describe "--javascript opt-out emits untyped .jsx (AC7)" do
|
|
193
|
+
before { run_scaffold(%w[Post title:string published:boolean], javascript: true) }
|
|
194
|
+
|
|
195
|
+
it "emits .jsx, never .tsx", :aggregate_failures do
|
|
196
|
+
%w[PostList PostForm PostDeleteDialog].each do |name|
|
|
197
|
+
expect(File).to exist(File.join(app_root, "app/javascript/components/#{name}.jsx"))
|
|
198
|
+
expect(File).not_to exist(File.join(app_root, "app/javascript/components/#{name}.tsx"))
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it "forfeits the TS-only FR99 types and FR100 contract, and says so", :aggregate_failures do
|
|
203
|
+
form = read("app/javascript/components/PostForm.jsx")
|
|
204
|
+
expect(form).not_to include("__ruactContract")
|
|
205
|
+
expect(form).not_to include("type PostRow")
|
|
206
|
+
expect(form).to include("forfeits")
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Story 10.2 / 10.2b — the List: a dep-free shadcn `table` primitive (10.2b
|
|
212
|
+
# moved it off the `@tanstack/react-table` DataTable recipe) + a generated
|
|
213
|
+
# client-side sort + a client-driven search query.
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
# Story 14.5 — re-pointed at the --shadcn path: the shadcn List table primitive
|
|
217
|
+
# is restored from behind the flag. The agnostic equivalents stay in :story_14_4.
|
|
218
|
+
describe "List is a shadcn table primitive (Story 10.2b AC1, AC2, AC3, AC4)" do
|
|
219
|
+
before do
|
|
220
|
+
run_scaffold(%w[Post title:string body:text published:boolean views:integer published_at:datetime],
|
|
221
|
+
shadcn: true)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
let(:list) { read("app/javascript/components/PostList.tsx") }
|
|
225
|
+
|
|
226
|
+
it "imports the shadcn `table` primitive + the other shadcn primitives", :aggregate_failures do
|
|
227
|
+
expect(list).to include(<<~TS.strip)
|
|
228
|
+
import {
|
|
229
|
+
Table,
|
|
230
|
+
TableBody,
|
|
231
|
+
TableCell,
|
|
232
|
+
TableHead,
|
|
233
|
+
TableHeader,
|
|
234
|
+
TableRow,
|
|
235
|
+
} from "@/components/ui/table";
|
|
236
|
+
TS
|
|
237
|
+
expect(list).to include('import { Badge } from "@/components/ui/badge"')
|
|
238
|
+
expect(list).to include('import { Button } from "@/components/ui/button"')
|
|
239
|
+
expect(list).to include('from "@/components/ui/dropdown-menu"')
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
it "drives the sort from a small generated useState + comparator, no table engine (AC2)",
|
|
243
|
+
:aggregate_failures do
|
|
244
|
+
# sort state: the active key + direction (or null = unsorted)
|
|
245
|
+
expect(list).to include('const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(null);')
|
|
246
|
+
# a generated comparator over the rows array (not the @tanstack engine API)
|
|
247
|
+
expect(list).to include("function compareRows(")
|
|
248
|
+
# date columns compare by epoch time; the generated date-keys set
|
|
249
|
+
expect(list).to include('const DATE_KEYS = new Set<string>(["published_at"]);')
|
|
250
|
+
expect(list).to include("new Date(String(av)).getTime() - new Date(String(bv)).getTime()")
|
|
251
|
+
# numbers numerically, booleans false<true, strings via localeCompare
|
|
252
|
+
expect(list).to include("result = av - bv;")
|
|
253
|
+
expect(list).to include("result = Number(av) - Number(bv);")
|
|
254
|
+
expect(list).to include("result = String(av).localeCompare(String(bv));")
|
|
255
|
+
# null/undefined always sort LAST, before the direction flip
|
|
256
|
+
expect(list).to include("if (av == null) return 1;")
|
|
257
|
+
expect(list).to include("if (bv == null) return -1;")
|
|
258
|
+
expect(list).to include('return sort.dir === "desc" ? -result : result;')
|
|
259
|
+
# always sorts a COPY — never mutates the source/prop array
|
|
260
|
+
expect(list).to include("const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it "renders a `table`-primitive header/body, reading row.<attr> directly (AC1)", :aggregate_failures do
|
|
264
|
+
expect(list).to include("<Table>")
|
|
265
|
+
expect(list).to include("<TableHeader>")
|
|
266
|
+
expect(list).to include("<TableBody>")
|
|
267
|
+
expect(list).to include("{sortedRows.map((row) => (")
|
|
268
|
+
expect(list).to include("<TableRow key={row.id}>")
|
|
269
|
+
# clickable header toggles the generated sort (no column.toggleSorting)
|
|
270
|
+
expect(list).to include('onClick={() => toggleSort("views")}')
|
|
271
|
+
# numeric column headers keep the right-aligned wrapper preserved from 10.2
|
|
272
|
+
expect(list).to include('<div className="text-right">')
|
|
273
|
+
# trailing actions header is non-sortable (a plain label, no toggleSort)
|
|
274
|
+
expect(list).to include('<TableHead className="text-right">Actions</TableHead>')
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
it "types each cell per attribute kind, reading row.<attr> (AC1)", :aggregate_failures do
|
|
278
|
+
# boolean → Badge "Yes"/"No"
|
|
279
|
+
expect(list).to include('variant={row.published ? "default" : "secondary"}>')
|
|
280
|
+
expect(list).to include('{row.published ? "Yes" : "No"}</Badge>')
|
|
281
|
+
# integer → right-aligned numeric cell (inner div preserved from 10.2)
|
|
282
|
+
expect(list).to include('<div className="text-right tabular-nums">{String(row.views ?? "")}</div>')
|
|
283
|
+
# datetime → locale-formatted cell (inner span preserved from 10.2)
|
|
284
|
+
expect(list).to include("new Date(String(row.published_at)).toLocaleString() : \"\"}</span>")
|
|
285
|
+
# string/text → plain text cell (inner span preserved from 10.2)
|
|
286
|
+
expect(list).to include("<span>{String(row.title ?? \"\")}</span>")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
it "has a per-row actions cell: Edit link + delete trigger, collapsing under a breakpoint (AC3)",
|
|
290
|
+
:aggregate_failures do
|
|
291
|
+
# the actions column is a trailing, NON-sortable header/cell (no toggleSort)
|
|
292
|
+
expect(list).to include('<TableHead className="text-right">Actions</TableHead>')
|
|
293
|
+
# the actions cell delegates to an in-file RowActions component (row.<attr>, not row.original)
|
|
294
|
+
expect(list).to include("<RowActions record={row} onDeleted={onDeleted} />")
|
|
295
|
+
expect(list).to include("<a href={`/posts/${record.id}/edit`}>Edit</a>")
|
|
296
|
+
# responsive overflow into a … DropdownMenu under `md` (controlled so the
|
|
297
|
+
# delete item can close it before opening the dialog)
|
|
298
|
+
expect(list).to include("md:hidden")
|
|
299
|
+
expect(list).to include("md:flex")
|
|
300
|
+
expect(list).to include("<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>")
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
it "gates the table on the server-rendered `posts` rows and keeps FR100 contract (AC4)", :aggregate_failures do
|
|
304
|
+
expect(list).to include("{sortedRows.length > 0 && (")
|
|
305
|
+
expect(list).to include("export const __ruactContract = {")
|
|
306
|
+
expect(list).to include('posts: "required"')
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
it "has a documented, prop-configurable empty state (AC4)", :aggregate_failures do
|
|
310
|
+
expect(list).to include('emptyLabel = "No posts yet — create one."')
|
|
311
|
+
expect(list).to include("{emptyLabel}")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
it "carries NO table-engine markers anywhere in the output (AC1, AC5)", :aggregate_failures do
|
|
315
|
+
expect(list).not_to include("@tanstack")
|
|
316
|
+
expect(list).not_to include("@/components/ui/data-table")
|
|
317
|
+
expect(list).not_to include("ColumnDef")
|
|
318
|
+
expect(list).not_to include("<DataTable")
|
|
319
|
+
expect(list).not_to include("row.getValue")
|
|
320
|
+
expect(list).not_to include("row.original")
|
|
321
|
+
expect(list).not_to include("toggleSorting")
|
|
322
|
+
expect(list).not_to include("getIsSorted")
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
describe "List wires the client-driven search query (Story 10.2 AC5)" do
|
|
327
|
+
before { run_scaffold }
|
|
328
|
+
|
|
329
|
+
let(:list) { read("app/javascript/components/PostList.tsx") }
|
|
330
|
+
|
|
331
|
+
it "imports the aliased search accessor + destroy + useQuery from the codegen module", :aggregate_failures do
|
|
332
|
+
expect(list).to include(
|
|
333
|
+
'import { search as searchPosts, destroyPost, useQuery } from "@/.ruact/server-functions"'
|
|
334
|
+
)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
it "drives the search box through useQuery(searchPosts, { q })", :aggregate_failures do
|
|
338
|
+
expect(list).to include("useQuery<PostRow[]>(searchPosts, { q: q.trim() })")
|
|
339
|
+
# while q is non-blank → query results; otherwise the server-rendered posts
|
|
340
|
+
# (Story 10.4 applies a removed-ids tombstone so a delete drops a row in place).
|
|
341
|
+
expect(list).to include("const source = searching ? searchData ?? [] : posts;")
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
it "uses a plural search accessor alias for a multi-word model" do
|
|
345
|
+
run_scaffold(%w[BlogPost title:string])
|
|
346
|
+
expect(read("app/javascript/components/BlogPostList.tsx"))
|
|
347
|
+
.to include("import { search as searchBlogPosts, destroyBlogPost, useQuery }")
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
describe "generated query class (Story 10.2 AC5)" do
|
|
352
|
+
before { run_scaffold(%w[Post title:string body:text published:boolean views:integer]) }
|
|
353
|
+
|
|
354
|
+
let(:query) { read("app/queries/posts_query.rb") }
|
|
355
|
+
|
|
356
|
+
it "creates app/queries/posts_query.rb < ApplicationQuery with a search(q:) method", :aggregate_failures do
|
|
357
|
+
expect(query).to start_with("# frozen_string_literal: true")
|
|
358
|
+
expect(query).to include("class PostsQuery < ApplicationQuery")
|
|
359
|
+
expect(query).to include("def search(q:)")
|
|
360
|
+
expect(query).to include("private")
|
|
361
|
+
expect(query).to include("def as_row(record)")
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
it "searches string/text columns only (AC5)", :aggregate_failures do
|
|
365
|
+
# string (title) + text (body) are searchable; published/views are NOT in
|
|
366
|
+
# the searched-columns list (they still appear in the as_row serialization).
|
|
367
|
+
expect(query).to include("columns = %w[title body]")
|
|
368
|
+
expect(query).to include("LIKE :needle ESCAPE '!'")
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
it "escapes wildcards + quotes columns + binds the value", :aggregate_failures do
|
|
372
|
+
expect(query).to include(%(Post.sanitize_sql_like(term.downcase, "!")))
|
|
373
|
+
# reserved-word-safe column quoting, per-adapter
|
|
374
|
+
expect(query).to include("Post.connection.quote_column_name(column)")
|
|
375
|
+
expect(query).to include("ESCAPE '!'")
|
|
376
|
+
expect(query).to include("needle: needle")
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
it "orders by id (no timestamps assumption)" do
|
|
380
|
+
expect(query).to include("scope.order(id: :desc)")
|
|
381
|
+
expect(query).not_to include("created_at")
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
it "blank q returns the whole collection (AC5)" do
|
|
385
|
+
expect(query).to include("if term.empty?")
|
|
386
|
+
expect(query).to include("Post.all")
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
it "renders syntactically valid Ruby (AC7)" do
|
|
390
|
+
expect { RubyVM::InstructionSequence.compile(query) }.not_to raise_error
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
it "emits the query .rb in --javascript mode too (language-agnostic)", :aggregate_failures do
|
|
394
|
+
run_scaffold(%w[Note body:text], javascript: true)
|
|
395
|
+
expect(File).to exist(File.join(app_root, "app/queries/notes_query.rb"))
|
|
396
|
+
expect(read("app/queries/notes_query.rb")).to include("class NotesQuery < ApplicationQuery")
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
it "returns all when idle / none when searched if no string/text column exists", :aggregate_failures do
|
|
400
|
+
run_scaffold(%w[Tally count:integer flag:boolean])
|
|
401
|
+
tally = read("app/queries/tallies_query.rb")
|
|
402
|
+
# a text search can't match a non-text model → empty on non-blank, all when idle
|
|
403
|
+
expect(tally).to include("term.empty? ? Tally.all : Tally.none")
|
|
404
|
+
expect(tally).not_to include("LIKE")
|
|
405
|
+
expect { RubyVM::InstructionSequence.compile(tally) }.not_to raise_error
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
describe "ApplicationQuery base — created idempotently (Story 10.2 AC5)" do
|
|
410
|
+
it "creates app/queries/application_query.rb < Ruact::Query when absent", :aggregate_failures do
|
|
411
|
+
run_scaffold
|
|
412
|
+
base = read("app/queries/application_query.rb")
|
|
413
|
+
expect(base).to start_with("# frozen_string_literal: true")
|
|
414
|
+
expect(base).to include("class ApplicationQuery < Ruact::Query")
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
it "does NOT clobber a customized ApplicationQuery on a second scaffold" do
|
|
418
|
+
run_scaffold
|
|
419
|
+
sentinel = "# frozen_string_literal: true\nclass ApplicationQuery < Ruact::Query\n # HAND EDITED\nend\n"
|
|
420
|
+
File.write(File.join(app_root, "app/queries/application_query.rb"), sentinel)
|
|
421
|
+
|
|
422
|
+
run_scaffold(%w[Comment body:text])
|
|
423
|
+
|
|
424
|
+
expect(read("app/queries/application_query.rb")).to eq(sentinel)
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
describe "routes inject ruact_queries (Story 10.2 AC5)" do
|
|
429
|
+
it "injects `ruact_queries PostsQuery` once, idempotently on re-run", :aggregate_failures do
|
|
430
|
+
run_scaffold
|
|
431
|
+
run_scaffold
|
|
432
|
+
routes = read("config/routes.rb")
|
|
433
|
+
expect(routes).to include("ruact_queries PostsQuery")
|
|
434
|
+
expect(routes.scan("ruact_queries PostsQuery").size).to eq(1)
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Story 14.5 — re-pointed at --shadcn: under `--shadcn --javascript` the shadcn
|
|
439
|
+
# `table` primitive survives, untyped. The agnostic --javascript List is in
|
|
440
|
+
# :story_14_4.
|
|
441
|
+
describe "--javascript List forfeits the TS-only markers (Story 10.2 AC6)" do
|
|
442
|
+
before { run_scaffold(%w[Post title:string published:boolean], javascript: true, shadcn: true) }
|
|
443
|
+
|
|
444
|
+
let(:list) { read("app/javascript/components/PostList.jsx") }
|
|
445
|
+
|
|
446
|
+
it "drops the TS-only markers but keeps the table primitive + sort + search wiring", :aggregate_failures do
|
|
447
|
+
expect(list).not_to include("type PostRow")
|
|
448
|
+
expect(list).not_to include("__ruactContract")
|
|
449
|
+
expect(list).to include("forfeits the FR99")
|
|
450
|
+
# the sort state + comparator are untyped in .jsx (no generic annotations)
|
|
451
|
+
expect(list).to include("const [sort, setSort] = useState(null);")
|
|
452
|
+
expect(list).to include("const DATE_KEYS = new Set([]);")
|
|
453
|
+
expect(list).to include("function compareRows(")
|
|
454
|
+
expect(list).not_to include(": PostRow")
|
|
455
|
+
expect(list).to include('return sort.dir === "desc" ? -result : result;')
|
|
456
|
+
# the shadcn `table` primitive, NOT the DataTable recipe
|
|
457
|
+
expect(list).to include('} from "@/components/ui/table"')
|
|
458
|
+
expect(list).to include("<Table>")
|
|
459
|
+
expect(list).to include("useQuery(searchPosts, { q: q.trim() })")
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
it "carries NO table-engine markers in the .jsx output either (AC5)", :aggregate_failures do
|
|
463
|
+
expect(list).not_to include("@tanstack")
|
|
464
|
+
expect(list).not_to include("@/components/ui/data-table")
|
|
465
|
+
expect(list).not_to include("ColumnDef")
|
|
466
|
+
expect(list).not_to include("<DataTable")
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# AC7 — the committed type-test fixture (type-checked by the JS job's
|
|
471
|
+
# `npm run typecheck` against the ambient shadcn stubs) MUST stay
|
|
472
|
+
# byte-identical to the generator's live output, or the typecheck proves
|
|
473
|
+
# nothing. This canonical model exercises every column kind.
|
|
474
|
+
# Story 14.5 — re-pointed at the --shadcn path: the byte-preserved shadcn
|
|
475
|
+
# PostList fixture is asserted equal to the `--shadcn` live output (restored
|
|
476
|
+
# from the 14.4 D4 skip). The agnostic fixture equality is in :story_14_4.
|
|
477
|
+
describe "generated .tsx matches the isolated-typecheck fixture (Story 10.2 AC7)" do
|
|
478
|
+
let(:fixture_path) { "vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx" }
|
|
479
|
+
|
|
480
|
+
it "PostList.tsx render stays byte-identical to the committed type-test fixture" do
|
|
481
|
+
run_scaffold(%w[Post title:string body:text published:boolean views:integer published_at:datetime
|
|
482
|
+
author:references], shadcn: true)
|
|
483
|
+
generated = read("app/javascript/components/PostList.tsx")
|
|
484
|
+
fixture = File.read(File.expand_path("../../#{fixture_path}", __dir__))
|
|
485
|
+
|
|
486
|
+
expect(generated).to eq(fixture),
|
|
487
|
+
"Generated PostList.tsx drifted from the type-test fixture. " \
|
|
488
|
+
"Regenerate it: rails g ruact:scaffold Post title:string body:text " \
|
|
489
|
+
"published:boolean views:integer published_at:datetime author:references " \
|
|
490
|
+
"→ copy app/javascript/components/PostList.tsx over #{fixture_path}, " \
|
|
491
|
+
"then re-run `npm run typecheck`."
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# ---------------------------------------------------------------------------
|
|
496
|
+
# Story 10.3 — shadcn Form with controls mapped by attribute type.
|
|
497
|
+
# ---------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
# Story 14.5 — re-pointed at --shadcn: the shadcn Form control mapping is
|
|
500
|
+
# restored from behind the flag. The agnostic control mapping is in :story_14_4.
|
|
501
|
+
describe "Form maps each attribute type to its shadcn control (Story 10.3 AC1, AC2)" do
|
|
502
|
+
before do
|
|
503
|
+
run_scaffold(%w[Post title:string body:text published:boolean views:integer
|
|
504
|
+
published_on:date published_at:datetime author:references], shadcn: true)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
let(:form) { read("app/javascript/components/PostForm.tsx") }
|
|
508
|
+
|
|
509
|
+
it "imports the shadcn primitives the controls use", :aggregate_failures do
|
|
510
|
+
expect(form).to include('import { Button } from "@/components/ui/button"')
|
|
511
|
+
expect(form).to include('import { Label } from "@/components/ui/label"')
|
|
512
|
+
expect(form).to include('import { Input } from "@/components/ui/input"')
|
|
513
|
+
expect(form).to include('import { Textarea } from "@/components/ui/textarea"')
|
|
514
|
+
expect(form).to include('import { Switch } from "@/components/ui/switch"')
|
|
515
|
+
expect(form).to include('from "@/components/ui/select"')
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
it "renders string → <Input type=\"text\">", :aggregate_failures do
|
|
519
|
+
expect(form).to include('<Label htmlFor="title">Title</Label>')
|
|
520
|
+
expect(form).to match(/<Input\s+id="title"\s+type="text"/m)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
it "renders text → <Textarea>", :aggregate_failures do
|
|
524
|
+
expect(form).to include('<Label htmlFor="body">Body</Label>')
|
|
525
|
+
expect(form).to match(/<Textarea\s+id="body"/m)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
it "renders boolean → <Switch> with onCheckedChange", :aggregate_failures do
|
|
529
|
+
expect(form).to match(/<Switch\s+id="published"/m)
|
|
530
|
+
expect(form).to include("onCheckedChange={setPublished}")
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
it "renders integer/float/decimal → <Input type=\"number\">" do
|
|
534
|
+
expect(form).to match(/<Input\s+id="views"\s+type="number"/m)
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
it "renders date → <Input type=\"date\"> (AC2 — native, wire format YYYY-MM-DD)" do
|
|
538
|
+
expect(form).to match(/<Input\s+id="published_on"\s+type="date"/m)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
it "renders datetime → <Input type=\"datetime-local\"> (AC2 — native, wire format YYYY-MM-DDTHH:MM)" do
|
|
542
|
+
expect(form).to match(/<Input\s+id="published_at"\s+type="datetime-local"/m)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
it "renders references → <Select> over the controller-provided options prop (AC6)", :aggregate_failures do
|
|
546
|
+
expect(form).to include('<Label htmlFor="author_id">Author</Label>')
|
|
547
|
+
expect(form).to include("onValueChange={setAuthorId}")
|
|
548
|
+
expect(form).to include("{authorOptions.map((option) => (")
|
|
549
|
+
expect(form).to include("<SelectItem key={option.id} value={String(option.id)}>")
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
it "wraps every field with a label + inline FR98 error from errorsFor(attr)", :aggregate_failures do
|
|
553
|
+
%w[title body published views published_on published_at author_id].each do |col|
|
|
554
|
+
expect(form).to include(%(errorsFor("#{col}")))
|
|
555
|
+
end
|
|
556
|
+
expect(form).to include('<p key={i} className="text-sm text-destructive">{message}</p>')
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
describe "Form preserves the v2 server-driven contract (Story 10.3 AC3, AC4, AC5)" do
|
|
561
|
+
before { run_scaffold(%w[Post title:string published:boolean author:references]) }
|
|
562
|
+
|
|
563
|
+
let(:form) { read("app/javascript/components/PostForm.tsx") }
|
|
564
|
+
|
|
565
|
+
it "imports ONLY the action accessors (no useQuery in the Form)", :aggregate_failures do
|
|
566
|
+
expect(form).to include('import { createPost, updatePost } from "@/.ruact/server-functions"')
|
|
567
|
+
expect(form).not_to include("useQuery")
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
it "keeps the controlled useState per attribute (string-valued, boolean via Boolean)", :aggregate_failures do
|
|
571
|
+
expect(form).to include("const [title, setTitle] = useState(String(initial?.title ?? \"\"));")
|
|
572
|
+
expect(form).to include("const [published, setPublished] = useState(Boolean(initial?.published ?? false));")
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
it "drives navigation SERVER-SIDE — no client URL building (AC5)", :aggregate_failures do
|
|
576
|
+
# the redirect is followed by the accessor; the component never assigns a URL
|
|
577
|
+
expect(form).not_to include("window.location.assign")
|
|
578
|
+
expect(form).not_to include("window.location")
|
|
579
|
+
expect(form).to include("? await updatePost({ id: initial.id, ...payload })")
|
|
580
|
+
expect(form).to include(": await createPost(payload))")
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
it "surfaces FR98 keyed errors inline + a top-level base block (AC3)", :aggregate_failures do
|
|
584
|
+
expect(form).to include("const [errors, setErrors] = useState<Record<string, string[]>>({});")
|
|
585
|
+
expect(form).to include("if (result && result.errors) {")
|
|
586
|
+
expect(form).to include('errorsFor("base").length > 0')
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
it "is typed (FR99 row) and carries the FR100 contract (initial optional) (AC4)", :aggregate_failures do
|
|
590
|
+
expect(form).to include("type PostRow = {")
|
|
591
|
+
expect(form).to include("export const __ruactContract")
|
|
592
|
+
expect(form).to include('initial: "optional"')
|
|
593
|
+
expect(form).to include("initial?: PostRow | null")
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
it "declares every references options prop in the FR100 contract (else 13.5 rejects the call site)" do
|
|
597
|
+
# the new/edit views pass `authorOptions`; an undeclared prop fails preprocess
|
|
598
|
+
expect(form).to include('authorOptions: "optional"')
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Story 14.5 — re-pointed at --shadcn: regenerate this describe's model under
|
|
602
|
+
# `--shadcn` (force-overwriting the agnostic `before` output) so the shadcn
|
|
603
|
+
# <Button> submit/cancel is asserted.
|
|
604
|
+
it "submits through a shadcn <Button type=\"submit\"> and a <Button> Cancel link", :aggregate_failures do
|
|
605
|
+
run_scaffold(%w[Post title:string published:boolean author:references], shadcn: true, force: true)
|
|
606
|
+
expect(form).to include('<Button type="submit" disabled={saving}>')
|
|
607
|
+
expect(form).to include('<Button variant="outline" asChild>')
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
describe "Form imports only the primitives the model's controls need (Story 10.3)" do
|
|
612
|
+
# Story 14.5 — re-pointed at --shadcn: the conditional-import predicates are a
|
|
613
|
+
# shadcn concern, restored from behind the flag.
|
|
614
|
+
it "a string-only model imports Input but not Textarea/Switch/Select", :aggregate_failures do
|
|
615
|
+
run_scaffold(%w[Tag name:string], shadcn: true)
|
|
616
|
+
form = read("app/javascript/components/TagForm.tsx")
|
|
617
|
+
expect(form).to include('import { Input } from "@/components/ui/input"')
|
|
618
|
+
expect(form).not_to include("@/components/ui/textarea")
|
|
619
|
+
expect(form).not_to include("@/components/ui/switch")
|
|
620
|
+
expect(form).not_to include("@/components/ui/select")
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
it "a reference-free model takes only the `initial` prop (no options prop)", :aggregate_failures do
|
|
624
|
+
run_scaffold(%w[Tag name:string])
|
|
625
|
+
form = read("app/javascript/components/TagForm.tsx")
|
|
626
|
+
expect(form).to include("export function TagForm({ initial = null }")
|
|
627
|
+
expect(form).to include('props: { initial: "optional" }')
|
|
628
|
+
expect(form).not_to include("Options = []")
|
|
629
|
+
expect(form).not_to include('Options: "optional"')
|
|
630
|
+
expect(form).not_to include("Options?:")
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
describe "references options sourcing — controller ivar → view prop (Story 10.3 AC6)" do
|
|
635
|
+
before { run_scaffold(%w[Post title:string author:references category:references]) }
|
|
636
|
+
|
|
637
|
+
it "loads a capped, labelled options ivar in new AND edit", :aggregate_failures do
|
|
638
|
+
controller = read("app/controllers/posts_controller.rb")
|
|
639
|
+
expect(controller).to include("@author_options = Author.limit(101).map")
|
|
640
|
+
expect(controller).to include("@category_options = Category.limit(101).map")
|
|
641
|
+
expect(controller).to include(%(record.try(:name) || record.try(:title) || record.to_s))
|
|
642
|
+
# both new and edit set the ivar
|
|
643
|
+
expect(controller.scan("@author_options = Author.limit(101)").size).to eq(2)
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
it "renders syntactically valid Ruby" do
|
|
647
|
+
controller = read("app/controllers/posts_controller.rb")
|
|
648
|
+
expect { RubyVM::InstructionSequence.compile(controller) }.not_to raise_error
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
it "passes the options ivar as a camelCase prop from new + edit views", :aggregate_failures do
|
|
652
|
+
new_view = read("app/views/posts/new.html.erb")
|
|
653
|
+
edit_view = read("app/views/posts/edit.html.erb")
|
|
654
|
+
expect(new_view).to include("<PostForm authorOptions={author_options} categoryOptions={category_options} />")
|
|
655
|
+
expect(edit_view).to include("authorOptions={author_options}")
|
|
656
|
+
expect(edit_view).to include("categoryOptions={category_options}")
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
it "types the options prop on the Form signature (FR99)", :aggregate_failures do
|
|
660
|
+
form = read("app/javascript/components/PostForm.tsx")
|
|
661
|
+
expect(form).to include("authorOptions = []")
|
|
662
|
+
expect(form).to include("authorOptions?: { id: number; label: string }[]")
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
it "documents the >threshold server-search combobox opt-in trail" do
|
|
666
|
+
form = read("app/javascript/components/PostForm.tsx")
|
|
667
|
+
expect(form).to include("server-search combobox")
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# Story 14.5 — re-pointed at --shadcn: under `--shadcn --javascript` the shadcn
|
|
672
|
+
# controls survive, untyped. The agnostic --javascript Form is in :story_14_4.
|
|
673
|
+
describe "--javascript Form forfeits TS markers but keeps the shadcn controls (Story 10.3 AC7)" do
|
|
674
|
+
before do
|
|
675
|
+
run_scaffold(%w[Post title:string body:text published:boolean author:references],
|
|
676
|
+
javascript: true, shadcn: true)
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
let(:form) { read("app/javascript/components/PostForm.jsx") }
|
|
680
|
+
|
|
681
|
+
it "drops type/__ruactContract but keeps the shadcn controls + behaviour", :aggregate_failures do
|
|
682
|
+
expect(form).not_to include("__ruactContract")
|
|
683
|
+
expect(form).not_to include("type PostRow")
|
|
684
|
+
expect(form).not_to include(": FormEvent")
|
|
685
|
+
expect(form).to include("forfeits")
|
|
686
|
+
expect(form).to include('import { Input } from "@/components/ui/input"')
|
|
687
|
+
expect(form).to include('import { Textarea } from "@/components/ui/textarea"')
|
|
688
|
+
expect(form).to include('import { Switch } from "@/components/ui/switch"')
|
|
689
|
+
expect(form).to include('from "@/components/ui/select"')
|
|
690
|
+
expect(form).to include("onCheckedChange={setPublished}")
|
|
691
|
+
expect(form).not_to include("window.location.assign")
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
it "still takes the options prop (untyped) for references", :aggregate_failures do
|
|
695
|
+
expect(form).to include("authorOptions = []")
|
|
696
|
+
expect(form).not_to include("{ id: number; label: string }[]")
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# AC8 — the committed Form fixture (type-checked by `npm run typecheck` against
|
|
701
|
+
# the ambient shadcn stubs) MUST stay byte-identical to the generator's live
|
|
702
|
+
# output. This canonical model exercises every shadcn control kind.
|
|
703
|
+
# Story 14.5 — re-pointed at the --shadcn path: the byte-preserved shadcn
|
|
704
|
+
# PostForm fixture is asserted equal to the `--shadcn` live output. The agnostic
|
|
705
|
+
# fixture equality is in :story_14_4.
|
|
706
|
+
describe "generated Form .tsx matches the isolated-typecheck fixture (Story 10.3 AC8)" do
|
|
707
|
+
let(:fixture_path) { "vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx" }
|
|
708
|
+
|
|
709
|
+
it "PostForm.tsx render stays byte-identical to the committed type-test fixture" do
|
|
710
|
+
run_scaffold(%w[Post title:string body:text published:boolean views:integer
|
|
711
|
+
published_on:date published_at:datetime author:references], shadcn: true)
|
|
712
|
+
generated = read("app/javascript/components/PostForm.tsx")
|
|
713
|
+
fixture = File.read(File.expand_path("../../#{fixture_path}", __dir__))
|
|
714
|
+
|
|
715
|
+
expect(generated).to eq(fixture),
|
|
716
|
+
"Generated PostForm.tsx drifted from the type-test fixture. " \
|
|
717
|
+
"Regenerate it: rails g ruact:scaffold Post title:string body:text " \
|
|
718
|
+
"published:boolean views:integer published_on:date published_at:datetime " \
|
|
719
|
+
"author:references → copy app/javascript/components/PostForm.tsx over " \
|
|
720
|
+
"#{fixture_path}, then re-run `npm run typecheck`."
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# ---------------------------------------------------------------------------
|
|
725
|
+
# Story 10.4 — controlled shadcn AlertDialog delete + List rewiring.
|
|
726
|
+
# ---------------------------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
# Story 14.5 — re-pointed at the --shadcn path: the controlled shadcn
|
|
729
|
+
# AlertDialog is restored from behind the flag. The agnostic native <dialog> is
|
|
730
|
+
# covered in :story_14_4.
|
|
731
|
+
describe "DeleteDialog is a controlled shadcn AlertDialog (Story 10.4 AC1, AC3, AC4, AC6)" do
|
|
732
|
+
before { run_scaffold(%w[Post title:string body:text published:boolean], shadcn: true) }
|
|
733
|
+
|
|
734
|
+
let(:dialog) { read("app/javascript/components/PostDeleteDialog.tsx") }
|
|
735
|
+
|
|
736
|
+
it "preserves the use-client directive on line 1" do
|
|
737
|
+
expect(dialog).to start_with(%("use client";))
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
it "imports the shadcn AlertDialog parts from @/components/ui/alert-dialog (AC1, AC7)", :aggregate_failures do
|
|
741
|
+
expect(dialog).to include('from "@/components/ui/alert-dialog"')
|
|
742
|
+
%w[
|
|
743
|
+
AlertDialog AlertDialogAction AlertDialogCancel AlertDialogContent
|
|
744
|
+
AlertDialogDescription AlertDialogFooter AlertDialogHeader AlertDialogTitle
|
|
745
|
+
].each { |part| expect(dialog).to include(part) }
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
it "is a CONTROLLED dialog with the contract props (AC1)", :aggregate_failures do
|
|
749
|
+
expect(dialog).to include("<AlertDialog open={open} onOpenChange={onOpenChange}>")
|
|
750
|
+
expect(dialog).to include("open: boolean;")
|
|
751
|
+
expect(dialog).to include("onOpenChange: (open: boolean) => void;")
|
|
752
|
+
expect(dialog).to include("post: PostRow;")
|
|
753
|
+
expect(dialog).to include("onConfirm: () => Promise<{ ok: boolean; error?: string }>;")
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
it "shows the Delete \"<title>\"? copy + cannot-be-undone body (AC1)", :aggregate_failures do
|
|
757
|
+
expect(dialog).to include('<AlertDialogTitle>Delete “{String(post.title ?? "")}”?</AlertDialogTitle>')
|
|
758
|
+
expect(dialog).to include("This action cannot be undone.")
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
it "has Cancel (AlertDialogCancel, default focus) + Delete (AlertDialogAction, destructive) (AC1, AC4)",
|
|
762
|
+
:aggregate_failures do
|
|
763
|
+
expect(dialog).to include("<AlertDialogCancel disabled={submitting}>Cancel</AlertDialogCancel>")
|
|
764
|
+
expect(dialog).to include("<AlertDialogAction")
|
|
765
|
+
expect(dialog).to include("bg-destructive")
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
it "calls onConfirm and STAYS OPEN with an inline error on failure (AC3)", :aggregate_failures do
|
|
769
|
+
expect(dialog).to include("const res = await onConfirm();")
|
|
770
|
+
expect(dialog).to include('setError(res.error ?? "Could not delete this post")')
|
|
771
|
+
expect(dialog).to include('{error && <p className="text-sm text-destructive">{error}</p>}')
|
|
772
|
+
# preventDefault keeps Radix from auto-closing the dialog mid-async
|
|
773
|
+
expect(dialog).to include("event.preventDefault();")
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
it "has NO Rails method=delete fallback and never builds a URL", :aggregate_failures do
|
|
777
|
+
# no native form-method override fallback (a real attr would be quoted;
|
|
778
|
+
# the doc comment's bare `method=delete` is not a live element attribute)
|
|
779
|
+
expect(dialog).not_to include('method="')
|
|
780
|
+
expect(dialog).not_to include("_method")
|
|
781
|
+
expect(dialog).not_to include("<form")
|
|
782
|
+
expect(dialog).not_to include("window.location")
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
it "retains the (inert) FR100 __ruactContract for uniformity (AC6)", :aggregate_failures do
|
|
786
|
+
expect(dialog).to include("export const __ruactContract")
|
|
787
|
+
expect(dialog).to include('post: "required"')
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
it "picks the first string display attribute (not hard-coded `title`) for the dialog heading" do
|
|
791
|
+
# A model whose first string attribute is NOT named `title` proves the
|
|
792
|
+
# display_attribute helper is not hard-coded.
|
|
793
|
+
run_scaffold(%w[Widget label:string body:text], shadcn: true)
|
|
794
|
+
widget = read("app/javascript/components/WidgetDeleteDialog.tsx")
|
|
795
|
+
expect(widget).to include('Delete “{String(widget.label ?? "")}”?')
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
it "falls back to a text column, then to id, when no string attribute exists", :aggregate_failures do
|
|
799
|
+
run_scaffold(%w[Note body:text], shadcn: true)
|
|
800
|
+
expect(read("app/javascript/components/NoteDeleteDialog.tsx"))
|
|
801
|
+
.to include('Delete “{String(note.body ?? "")}”?')
|
|
802
|
+
|
|
803
|
+
run_scaffold(%w[Counter tally:integer], shadcn: true)
|
|
804
|
+
expect(read("app/javascript/components/CounterDeleteDialog.tsx"))
|
|
805
|
+
.to include('Delete “{String(counter.id ?? "")}”?')
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
describe "List rewires the controlled dialog + in-list removal (Story 10.4 AC2, AC5)" do
|
|
810
|
+
before { run_scaffold(%w[Post title:string body:text published:boolean views:integer]) }
|
|
811
|
+
|
|
812
|
+
let(:list) { read("app/javascript/components/PostList.tsx") }
|
|
813
|
+
|
|
814
|
+
it "imports the destroy accessor alongside the search wiring", :aggregate_failures do
|
|
815
|
+
expect(list).to include('import { useState } from "react"')
|
|
816
|
+
expect(list).to include("destroyPost")
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
it "drives a per-row controlled dialog via an in-file RowActions (own open state)", :aggregate_failures do
|
|
820
|
+
expect(list).to include("function RowActions({ record, onDeleted }")
|
|
821
|
+
expect(list).to include("const [open, setOpen] = useState(false);")
|
|
822
|
+
expect(list).to include("<PostDeleteDialog")
|
|
823
|
+
expect(list).to include("open={open}")
|
|
824
|
+
expect(list).to include("onOpenChange={setOpen}")
|
|
825
|
+
expect(list).to include("post={record}")
|
|
826
|
+
expect(list).to include("onConfirm={onConfirm}")
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
it "checks the { ok: true } / redirect success shape before removing the row (AC2)", :aggregate_failures do
|
|
830
|
+
expect(list).to include("await destroyPost({ id: record.id });")
|
|
831
|
+
expect(list).to include("if (deleteSucceeded(result)) {")
|
|
832
|
+
expect(list).to include("onDeleted(record.id);")
|
|
833
|
+
# success shape helper: in-list { ok: true } or the redirect-followed null
|
|
834
|
+
expect(list).to include("function deleteSucceeded(result: unknown): boolean")
|
|
835
|
+
expect(list).to include("if (result === null) return true;")
|
|
836
|
+
expect(list).to include(").ok === true")
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
it "removes the row via a removed-ids tombstone applied to the displayed source (AC2, AC5)",
|
|
840
|
+
:aggregate_failures do
|
|
841
|
+
expect(list).to include("const [removedIds, setRemovedIds] = useState<number[]>([]);")
|
|
842
|
+
expect(list).to include("setRemovedIds((current) => (current.includes(id) ? current : [...current, id]));")
|
|
843
|
+
# the tombstone filters WHICHEVER source is shown — search results included
|
|
844
|
+
expect(list).to include("const source = searching ? searchData ?? [] : posts;")
|
|
845
|
+
expect(list).to include("source.filter((row) => !removedIds.includes(row.id))")
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
it "keeps the dialog OPEN with the structured-error message on failure (AC3)", :aggregate_failures do
|
|
849
|
+
expect(list).to include("return { ok: false, error: deleteErrorMessage(error) };")
|
|
850
|
+
expect(list).to include("function deleteErrorMessage(error: unknown): string | undefined")
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
# Story 14.5 — re-pointed at --shadcn: regenerate this describe's model under
|
|
854
|
+
# `--shadcn` (force-overwriting the agnostic `before` output) so the
|
|
855
|
+
# responsive shadcn DropdownMenu overflow is asserted.
|
|
856
|
+
it "drives the trigger from BOTH layouts (inline ≥ md + overflow menu < md) (AC5)", :aggregate_failures do
|
|
857
|
+
run_scaffold(%w[Post title:string body:text published:boolean views:integer], shadcn: true, force: true)
|
|
858
|
+
expect(list).to include("onClick={() => setOpen(true)}")
|
|
859
|
+
expect(list).to include("md:flex")
|
|
860
|
+
expect(list).to include("md:hidden")
|
|
861
|
+
# the overflow menu is controlled so the item closes it before opening the dialog
|
|
862
|
+
expect(list).to include("<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>")
|
|
863
|
+
expect(list).to include("event.preventDefault();")
|
|
864
|
+
expect(list).to include("setMenuOpen(false);")
|
|
865
|
+
expect(list).to include("setOpen(true);")
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
it "shows the empty state from the displayed rows (so deleting the last row empties) (AC5)",
|
|
869
|
+
:aggregate_failures do
|
|
870
|
+
expect(list).to include("!searching && rows.length === 0")
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
it "PRESERVES the 10.2 search + FR99/FR100 + empty state (no regression)", :aggregate_failures do
|
|
874
|
+
expect(list).to include(
|
|
875
|
+
'import { search as searchPosts, destroyPost, useQuery } from "@/.ruact/server-functions"'
|
|
876
|
+
)
|
|
877
|
+
expect(list).to include("export const __ruactContract = {")
|
|
878
|
+
expect(list).to include('posts: "required"')
|
|
879
|
+
expect(list).to include("type PostRow = {")
|
|
880
|
+
expect(list).to include("useQuery<PostRow[]>(searchPosts, { q: q.trim() })")
|
|
881
|
+
expect(list).to include('emptyLabel = "No posts yet — create one."')
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
describe "controller #destroy aligns to the in-list { ok: true } default (Story 10.4 AC2, AC3)" do
|
|
886
|
+
before { run_scaffold(%w[Post title:string published:boolean]) }
|
|
887
|
+
|
|
888
|
+
let(:controller) { read("app/controllers/posts_controller.rb") }
|
|
889
|
+
|
|
890
|
+
it "sets @ok = true; @id with no live redirect, using destroy! (AC3)", :aggregate_failures do
|
|
891
|
+
expect(controller).to match(/def destroy.*post\.destroy!.*@ok = true.*@id = post\.id/m)
|
|
892
|
+
# the redirect alternative is present only as a commented opt-in
|
|
893
|
+
expect(controller).to include("# redirect_to posts_url")
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
it "does NOT live-redirect from destroy", :aggregate_failures do
|
|
897
|
+
destroy_body = controller[/def destroy.*?\n end/m]
|
|
898
|
+
expect(destroy_body).not_to match(/^\s*redirect_to /)
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
it "does NOT hand-roll a rescue (gem structured-error middleware owns failure) (AC3)" do
|
|
902
|
+
destroy_body = controller[/def destroy.*?\n end/m]
|
|
903
|
+
expect(destroy_body).not_to include("rescue")
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
it "renders syntactically valid Ruby" do
|
|
907
|
+
expect { RubyVM::InstructionSequence.compile(controller) }.not_to raise_error
|
|
908
|
+
end
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
# Story 14.5 — re-pointed at --shadcn: under `--shadcn --javascript` the
|
|
912
|
+
# AlertDialog survives, untyped. The agnostic --javascript dialog is in
|
|
913
|
+
# :story_14_4.
|
|
914
|
+
describe "--javascript DeleteDialog forfeits TS markers but keeps the AlertDialog (Story 10.4 AC6)" do
|
|
915
|
+
before { run_scaffold(%w[Post title:string published:boolean], javascript: true, shadcn: true) }
|
|
916
|
+
|
|
917
|
+
let(:dialog) { read("app/javascript/components/PostDeleteDialog.jsx") }
|
|
918
|
+
|
|
919
|
+
it "drops type/__ruactContract/TS prop types but keeps the controlled AlertDialog", :aggregate_failures do
|
|
920
|
+
expect(dialog).not_to include("__ruactContract")
|
|
921
|
+
expect(dialog).not_to include("type PostRow")
|
|
922
|
+
expect(dialog).not_to include("Promise<{ ok")
|
|
923
|
+
expect(dialog).to include("forfeits")
|
|
924
|
+
expect(dialog).to include('from "@/components/ui/alert-dialog"')
|
|
925
|
+
expect(dialog).to include("const res = await onConfirm();")
|
|
926
|
+
end
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
# AC7 — the committed DeleteDialog fixture (type-checked by `npm run typecheck`
|
|
930
|
+
# against the ambient @/components/ui/alert-dialog stub) MUST stay byte-identical
|
|
931
|
+
# to the generator's live output. Uses the SAME model as the PostList fixture so
|
|
932
|
+
# their `PostRow` shapes line up when PostList imports ./PostDeleteDialog.
|
|
933
|
+
# Story 14.5 — re-pointed at the --shadcn path: the byte-preserved shadcn
|
|
934
|
+
# PostDeleteDialog fixture is asserted equal to the `--shadcn` live output. The
|
|
935
|
+
# agnostic fixture equality is in :story_14_4.
|
|
936
|
+
describe "generated DeleteDialog .tsx matches the isolated-typecheck fixture (Story 10.4 AC7)" do
|
|
937
|
+
let(:fixture_path) { "vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx" }
|
|
938
|
+
|
|
939
|
+
it "PostDeleteDialog.tsx render stays byte-identical to the committed type-test fixture" do
|
|
940
|
+
run_scaffold(%w[Post title:string body:text published:boolean views:integer published_at:datetime
|
|
941
|
+
author:references], shadcn: true)
|
|
942
|
+
generated = read("app/javascript/components/PostDeleteDialog.tsx")
|
|
943
|
+
fixture = File.read(File.expand_path("../../#{fixture_path}", __dir__))
|
|
944
|
+
|
|
945
|
+
expect(generated).to eq(fixture),
|
|
946
|
+
"Generated PostDeleteDialog.tsx drifted from the type-test fixture. " \
|
|
947
|
+
"Regenerate it: rails g ruact:scaffold Post title:string body:text " \
|
|
948
|
+
"published:boolean views:integer published_at:datetime author:references " \
|
|
949
|
+
"→ copy app/javascript/components/PostDeleteDialog.tsx over #{fixture_path}, " \
|
|
950
|
+
"then re-run `npm run typecheck`."
|
|
951
|
+
end
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
describe "unknown attribute type fails clearly before writing (AC4)" do
|
|
955
|
+
# Enforced at construction (parse_attributes!) so it fires BEFORE Rails'
|
|
956
|
+
# GeneratedAttribute — whose own unknown-type error neither lists ruact's
|
|
957
|
+
# types nor points at the docs (and reaches for a DB once AR is loaded).
|
|
958
|
+
it "raises Thor::Error listing the supported types + docs pointer", :aggregate_failures do
|
|
959
|
+
expect { build(%w[Foo bar:custom_unknown_type]) }.to raise_error(Thor::Error) do |error|
|
|
960
|
+
expect(error.message).to include("bar:custom_unknown_type")
|
|
961
|
+
expect(error.message).to include("string, text, integer, float, decimal, boolean, date, datetime, references")
|
|
962
|
+
expect(error.message).to include("scaffold.md#attribute-types")
|
|
963
|
+
end
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
it "writes no partial output when the type is unknown" do
|
|
967
|
+
begin
|
|
968
|
+
build(%w[Foo bar:custom_unknown_type])
|
|
969
|
+
rescue Thor::Error
|
|
970
|
+
# expected — construction aborts before any file is written
|
|
971
|
+
end
|
|
972
|
+
expect(File).not_to exist(File.join(app_root, "app/controllers/foos_controller.rb"))
|
|
973
|
+
end
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
describe "edge cases (Codex review round 1)" do
|
|
977
|
+
it "fails loud on a namespaced resource rather than emit broken output" do
|
|
978
|
+
expect { build(%w[Admin::Post title:string]) }.to raise_error(Thor::Error, /namespaced resources/)
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
it "coerces date/datetime/decimal row values to their wire+control type", :aggregate_failures do
|
|
982
|
+
run_scaffold(%w[Event name:string starts_at:datetime on:date price:decimal])
|
|
983
|
+
index = read("app/views/events/index.html.erb")
|
|
984
|
+
# datetime → datetime-local-valid string (no seconds/offset)
|
|
985
|
+
expect(index).to include('starts_at&.strftime("%Y-%m-%dT%H:%M")')
|
|
986
|
+
# date → YYYY-MM-DD
|
|
987
|
+
expect(index).to include("on&.iso8601")
|
|
988
|
+
# decimal (BigDecimal serializes as a string) → number, matching ts:number
|
|
989
|
+
expect(index).to include("price&.to_f")
|
|
990
|
+
# a scalar string column is NOT wrapped
|
|
991
|
+
expect(index).to include('"name" => event.name')
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
it "renders inline errors for a boolean field too" do
|
|
995
|
+
run_scaffold(%w[Post title:string published:boolean])
|
|
996
|
+
form = read("app/javascript/components/PostForm.tsx")
|
|
997
|
+
expect(form).to include('errorsFor("published")')
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
it "addresses a references column as <name>_id in the (nullable) row type" do
|
|
1001
|
+
run_scaffold(%w[Comment body:text author:references])
|
|
1002
|
+
list = read("app/javascript/components/CommentList.tsx")
|
|
1003
|
+
# FR99 wire union: attribute columns are nullable; the PK id is not.
|
|
1004
|
+
expect(list).to include("author_id: number | null")
|
|
1005
|
+
expect(list).to include("id: number;")
|
|
1006
|
+
end
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
describe "idempotent / non-destructive re-run (AC5)" do
|
|
1010
|
+
it "injects `resources :posts` only once across re-runs" do
|
|
1011
|
+
run_scaffold
|
|
1012
|
+
run_scaffold
|
|
1013
|
+
expect(read("config/routes.rb").scan("resources :posts").size).to eq(1)
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
# The overlay TEMPLATE files that are not force-pinned (views/components/the
|
|
1017
|
+
# query) still honor Thor's --skip/--force on re-run. (The controller and the
|
|
1018
|
+
# RSpec smoke spec force-overwrite by design — Story 14.3 D2/D4 — covered in
|
|
1019
|
+
# the :story_14_3 block below.) Exercised here on the index view.
|
|
1020
|
+
it "does NOT silently overwrite an existing overlay-template file when skipped" do
|
|
1021
|
+
run_scaffold
|
|
1022
|
+
sentinel = "<%# HAND EDITED — do not clobber %>\n"
|
|
1023
|
+
File.write(File.join(app_root, "app/views/posts/index.html.erb"), sentinel)
|
|
1024
|
+
|
|
1025
|
+
gen = build(%w[Post title:string body:text published:boolean], skip: true)
|
|
1026
|
+
silently { gen.create_views }
|
|
1027
|
+
|
|
1028
|
+
expect(read("app/views/posts/index.html.erb")).to eq(sentinel)
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
it "overwrites an overlay-template file only when --force is given" do
|
|
1032
|
+
run_scaffold
|
|
1033
|
+
File.write(File.join(app_root, "app/views/posts/index.html.erb"), "<%# stale %>\n")
|
|
1034
|
+
|
|
1035
|
+
gen = build(%w[Post title:string body:text published:boolean], force: true)
|
|
1036
|
+
silently { gen.create_views }
|
|
1037
|
+
|
|
1038
|
+
expect(read("app/views/posts/index.html.erb")).to include("<PostList posts={rows} />")
|
|
1039
|
+
end
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
describe "generated smoke spec content (AC6)" do
|
|
1043
|
+
before { run_scaffold }
|
|
1044
|
+
|
|
1045
|
+
let(:spec_file) { read("spec/requests/posts_spec.rb") }
|
|
1046
|
+
|
|
1047
|
+
it "is a request spec exercising the v2 contract", :aggregate_failures do
|
|
1048
|
+
expect(spec_file).to include("type: :request")
|
|
1049
|
+
expect(spec_file).to include(%("Accept" => "application/json"))
|
|
1050
|
+
expect(spec_file).to include("change(Post, :count)")
|
|
1051
|
+
expect(spec_file).to include("errors")
|
|
1052
|
+
expect(spec_file).to start_with("# frozen_string_literal: true")
|
|
1053
|
+
end
|
|
1054
|
+
end
|
|
1055
|
+
|
|
1056
|
+
# ---------------------------------------------------------------------------
|
|
1057
|
+
# Story 10.5 — shadcn dependency pre-flight: detect (complete / missing /
|
|
1058
|
+
# partial), guide (copy-pasteable `npx shadcn` commands, never auto-run),
|
|
1059
|
+
# validate the installed shadcn major against `shadcn_compatible_versions`.
|
|
1060
|
+
# Fixture-driven (no network — the real `npx shadcn` install is Story 10.7).
|
|
1061
|
+
# ---------------------------------------------------------------------------
|
|
1062
|
+
# Story 14.5 (FR103b, D1/D4) — the shadcn pre-flight is RE-PROMOTED to a Thor
|
|
1063
|
+
# command, now `--shadcn`-GATED: `check_shadcn_setup` runs the byte-preserved
|
|
1064
|
+
# ShadcnPreflight detection / guidance / abort only under `--shadcn` (the
|
|
1065
|
+
# default, agnostic path no-ops it — proven in :story_14_4 / :story_14_5). This
|
|
1066
|
+
# whole Story 10.5 block re-points at the `--shadcn` path via a local `build`
|
|
1067
|
+
# override that opts every gen in (so `check_shadcn_setup` actually runs the
|
|
1068
|
+
# pre-flight); each example body is otherwise the Epic 10 assertion, restored.
|
|
1069
|
+
# The two in-file BANNER examples — neutralized in 14.4 because the agnostic
|
|
1070
|
+
# templates carry no banner — are un-skipped here: under `--shadcn`,
|
|
1071
|
+
# `create_components` renders the shadcn templates whose banner reappears.
|
|
1072
|
+
describe "shadcn dependency pre-flight (Story 10.5)", :story_10_5 do
|
|
1073
|
+
# Story 14.5 — opt every build in this block into the `--shadcn` path so the
|
|
1074
|
+
# re-promoted `check_shadcn_setup` runs the pre-flight (without the flag it is
|
|
1075
|
+
# a no-op). The read-only helpers (`required_shadcn_components`,
|
|
1076
|
+
# `missing_shadcn_message`, `installed_shadcn_major`, …) are flag-independent,
|
|
1077
|
+
# so they behave identically — only `check_shadcn_setup`'s gate is restored.
|
|
1078
|
+
def build(args, options = {})
|
|
1079
|
+
super(args, { shadcn: true }.merge(options))
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
# The default model exercises input (string), textarea (text), switch
|
|
1083
|
+
# (boolean). The add-list is DERIVED from the generator, so these fixtures
|
|
1084
|
+
# never drift from the emitted import set.
|
|
1085
|
+
let(:default_args) { %w[Post title:string body:text published:boolean] }
|
|
1086
|
+
let(:full_required) { build(default_args).required_shadcn_components }
|
|
1087
|
+
|
|
1088
|
+
def capture_stdout
|
|
1089
|
+
original = $stdout
|
|
1090
|
+
$stdout = StringIO.new
|
|
1091
|
+
yield
|
|
1092
|
+
$stdout.string
|
|
1093
|
+
ensure
|
|
1094
|
+
$stdout = original
|
|
1095
|
+
end
|
|
1096
|
+
|
|
1097
|
+
def write_components_json
|
|
1098
|
+
File.write(File.join(app_root, "components.json"), %({ "style": "default" }\n))
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
def write_ui_components(names)
|
|
1102
|
+
ui = File.join(app_root, "app/javascript/components/ui")
|
|
1103
|
+
FileUtils.mkdir_p(ui)
|
|
1104
|
+
names.each { |name| File.write(File.join(ui, "#{name}.tsx"), "export const stub = {};\n") }
|
|
1105
|
+
end
|
|
1106
|
+
|
|
1107
|
+
def write_package_json(sections)
|
|
1108
|
+
File.write(File.join(app_root, "package.json"), JSON.generate(sections))
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
# Runs the FULL generator including the pre-flight as the first step.
|
|
1112
|
+
def run_full_scaffold(args = default_args, options = {})
|
|
1113
|
+
gen = build(args, options)
|
|
1114
|
+
silently do
|
|
1115
|
+
gen.check_shadcn_setup
|
|
1116
|
+
gen.create_controller
|
|
1117
|
+
gen.add_resource_route
|
|
1118
|
+
gen.create_queries
|
|
1119
|
+
gen.add_query_route
|
|
1120
|
+
gen.create_views
|
|
1121
|
+
gen.create_components
|
|
1122
|
+
gen.create_smoke_spec
|
|
1123
|
+
end
|
|
1124
|
+
gen
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
describe "add-list derivation (AC1, AC7)" do
|
|
1128
|
+
it "derives the full add-list from the templates' own predicates (no hardcoded drift)" do
|
|
1129
|
+
full = build(%w[Post title:string body:text published:boolean author:references])
|
|
1130
|
+
.required_shadcn_components
|
|
1131
|
+
expect(full).to eq(%w[button input textarea switch select label badge table alert-dialog dropdown-menu])
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
it "drops the conditional input-family a model does not use" do
|
|
1135
|
+
minimal = build(%w[Tag name:string]).required_shadcn_components
|
|
1136
|
+
expect(minimal).to eq(%w[button input label badge table alert-dialog dropdown-menu])
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
it "maps each component to its app/javascript/components/ui/<name>.tsx file" do
|
|
1140
|
+
gen = build(default_args)
|
|
1141
|
+
expect(gen.shadcn_component_file("table").to_s)
|
|
1142
|
+
.to end_with("app/javascript/components/ui/table.tsx")
|
|
1143
|
+
end
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
describe "complete setup → reuse, proceed (AC1)" do
|
|
1147
|
+
before do
|
|
1148
|
+
write_components_json
|
|
1149
|
+
write_ui_components(full_required)
|
|
1150
|
+
write_package_json({ "devDependencies" => { "shadcn" => "^2.1.0" } })
|
|
1151
|
+
end
|
|
1152
|
+
|
|
1153
|
+
it "proceeds and writes the scaffold without aborting" do
|
|
1154
|
+
expect { run_full_scaffold }.not_to raise_error
|
|
1155
|
+
expect(File).to exist(File.join(app_root, "app/controllers/posts_controller.rb"))
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
it "reuses the existing ui/* files (creates no duplicate, leaves them untouched)" do
|
|
1159
|
+
run_full_scaffold
|
|
1160
|
+
# The generator imports ui/* but never writes them — the stub content is intact.
|
|
1161
|
+
expect(read("app/javascript/components/ui/button.tsx")).to eq("export const stub = {};\n")
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
it "emits .tsx components carrying the FR99/FR100 markers (unchanged, no banner)", :aggregate_failures do
|
|
1165
|
+
run_full_scaffold
|
|
1166
|
+
list = read("app/javascript/components/PostList.tsx")
|
|
1167
|
+
expect(list).to include("export const __ruactContract")
|
|
1168
|
+
expect(list).not_to include("--skip-shadcn-check")
|
|
1169
|
+
end
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
describe "missing setup → print init + add, abort, write nothing (AC2)" do
|
|
1173
|
+
it "raises Thor::Error with the init + single add <full list> sequence", :aggregate_failures do
|
|
1174
|
+
gen = build(default_args)
|
|
1175
|
+
silently do
|
|
1176
|
+
expect { gen.check_shadcn_setup }.to raise_error(Thor::Error) do |error|
|
|
1177
|
+
expect(error.message).to include("npx shadcn@latest init")
|
|
1178
|
+
expect(error.message)
|
|
1179
|
+
.to include("npx shadcn@latest add button input textarea switch label badge " \
|
|
1180
|
+
"table alert-dialog dropdown-menu")
|
|
1181
|
+
expect(error.message).to include("--skip-shadcn-check")
|
|
1182
|
+
end
|
|
1183
|
+
end
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1186
|
+
it "includes label, badge, AND table in the add-list (corrects the epic's stale list)", :aggregate_failures do
|
|
1187
|
+
gen = build(default_args)
|
|
1188
|
+
message = gen.missing_shadcn_message
|
|
1189
|
+
expect(message).to include("label")
|
|
1190
|
+
expect(message).to include("badge")
|
|
1191
|
+
expect(message).to include("table")
|
|
1192
|
+
end
|
|
1193
|
+
|
|
1194
|
+
it "writes NO scaffold file (no partial state)", :aggregate_failures do
|
|
1195
|
+
gen = build(default_args)
|
|
1196
|
+
silently { expect { gen.check_shadcn_setup }.to raise_error(Thor::Error) }
|
|
1197
|
+
%w[
|
|
1198
|
+
app/controllers/posts_controller.rb
|
|
1199
|
+
app/javascript/components/PostList.tsx
|
|
1200
|
+
app/queries/posts_query.rb
|
|
1201
|
+
spec/requests/posts_spec.rb
|
|
1202
|
+
].each { |path| expect(File).not_to exist(File.join(app_root, path)) }
|
|
1203
|
+
# the route line is never injected either
|
|
1204
|
+
expect(read("config/routes.rb")).not_to include("resources :posts")
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
it "treats a present ui/ dir WITHOUT components.json as missing — init + FULL add, never a bare add",
|
|
1208
|
+
:aggregate_failures do
|
|
1209
|
+
# Regression (Codex R1): a config-less app with ui/* files present must
|
|
1210
|
+
# route to the init+full-add guidance, not a `:partial` with an empty add.
|
|
1211
|
+
write_ui_components(full_required)
|
|
1212
|
+
gen = build(default_args)
|
|
1213
|
+
silently do
|
|
1214
|
+
expect { gen.check_shadcn_setup }.to raise_error(Thor::Error) do |error|
|
|
1215
|
+
expect(error.message).to include("npx shadcn@latest init")
|
|
1216
|
+
expect(error.message)
|
|
1217
|
+
.to include("npx shadcn@latest add button input textarea switch label badge " \
|
|
1218
|
+
"table alert-dialog dropdown-menu")
|
|
1219
|
+
expect(error.message).not_to match(/add\s*$/) # no bare/empty `add` line
|
|
1220
|
+
end
|
|
1221
|
+
end
|
|
1222
|
+
end
|
|
1223
|
+
end
|
|
1224
|
+
|
|
1225
|
+
describe "partial setup → list exactly the missing + targeted add, abort (AC4)" do
|
|
1226
|
+
before do
|
|
1227
|
+
write_components_json
|
|
1228
|
+
# everything present EXCEPT badge + table
|
|
1229
|
+
write_ui_components(full_required - %w[badge table])
|
|
1230
|
+
end
|
|
1231
|
+
|
|
1232
|
+
it "lists exactly the missing components and the targeted add command", :aggregate_failures do
|
|
1233
|
+
gen = build(default_args)
|
|
1234
|
+
silently do
|
|
1235
|
+
expect { gen.check_shadcn_setup }.to raise_error(Thor::Error) do |error|
|
|
1236
|
+
expect(error.message).to include("badge, table")
|
|
1237
|
+
expect(error.message).to include("npx shadcn@latest add badge table")
|
|
1238
|
+
# a partial setup does NOT re-run init
|
|
1239
|
+
expect(error.message).not_to include("npx shadcn@latest init")
|
|
1240
|
+
expect(error.message).not_to include("button input")
|
|
1241
|
+
end
|
|
1242
|
+
end
|
|
1243
|
+
end
|
|
1244
|
+
|
|
1245
|
+
it "does NOT auto-run any npx/npm command and writes nothing", :aggregate_failures do
|
|
1246
|
+
gen = build(default_args)
|
|
1247
|
+
silently { expect { gen.check_shadcn_setup }.to raise_error(Thor::Error) }
|
|
1248
|
+
expect(File).not_to exist(File.join(app_root, "app/controllers/posts_controller.rb"))
|
|
1249
|
+
end
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
describe "--skip-shadcn-check → write anyway with an in-file banner (AC3)" do
|
|
1253
|
+
it "writes the scaffold despite a missing setup", :aggregate_failures do
|
|
1254
|
+
expect { run_full_scaffold(default_args, skip_shadcn_check: true) }.not_to raise_error
|
|
1255
|
+
expect(File).to exist(File.join(app_root, "app/controllers/posts_controller.rb"))
|
|
1256
|
+
end
|
|
1257
|
+
|
|
1258
|
+
# Story 14.5 — under `--shadcn` (this block's build override) the shadcn
|
|
1259
|
+
# templates render, so `--skip-shadcn-check` re-surfaces the in-file banner.
|
|
1260
|
+
it "emits a prominent banner in each component naming the unresolved imports + the fix", :aggregate_failures do
|
|
1261
|
+
run_full_scaffold(default_args, skip_shadcn_check: true)
|
|
1262
|
+
%w[PostList PostForm PostDeleteDialog].each do |name|
|
|
1263
|
+
component = read("app/javascript/components/#{name}.tsx")
|
|
1264
|
+
expect(component).to include("--skip-shadcn-check")
|
|
1265
|
+
expect(component).to include("@/components/ui/*")
|
|
1266
|
+
expect(component).to include("npx shadcn@latest add")
|
|
1267
|
+
end
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1270
|
+
it "does NOT emit the banner when the setup is complete (byte-stable default path)", :aggregate_failures do
|
|
1271
|
+
write_components_json
|
|
1272
|
+
write_ui_components(full_required)
|
|
1273
|
+
run_full_scaffold(default_args, skip_shadcn_check: true)
|
|
1274
|
+
expect(read("app/javascript/components/PostList.tsx")).not_to include("--skip-shadcn-check")
|
|
1275
|
+
end
|
|
1276
|
+
|
|
1277
|
+
# Story 14.5 — re-pointed at the `--shadcn` path (this block's build override).
|
|
1278
|
+
it "the banner never prints a bare add when ui/ files exist but components.json is absent" do
|
|
1279
|
+
# Regression (Codex R1): state is :missing (no config) → missing list is
|
|
1280
|
+
# empty → the banner falls back to the FULL required add-list.
|
|
1281
|
+
write_ui_components(full_required)
|
|
1282
|
+
run_full_scaffold(default_args, skip_shadcn_check: true)
|
|
1283
|
+
expect(read("app/javascript/components/PostList.tsx"))
|
|
1284
|
+
.to include("npx shadcn@latest add button input textarea switch label badge " \
|
|
1285
|
+
"table alert-dialog dropdown-menu")
|
|
1286
|
+
end
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
describe "version-compat validation against shadcn_compatible_versions (AC6, AC8)" do
|
|
1290
|
+
before do
|
|
1291
|
+
write_components_json
|
|
1292
|
+
write_ui_components(full_required)
|
|
1293
|
+
end
|
|
1294
|
+
|
|
1295
|
+
it "does NOT warn when the installed major is in the compatible list (v2)", :aggregate_failures do
|
|
1296
|
+
write_package_json({ "devDependencies" => { "shadcn" => "^2.1.0" } })
|
|
1297
|
+
output = capture_stdout { build(default_args).check_shadcn_setup }
|
|
1298
|
+
expect(output).not_to include("not regression-tested")
|
|
1299
|
+
end
|
|
1300
|
+
|
|
1301
|
+
it "does NOT warn for a prior compatible major (v1)", :aggregate_failures do
|
|
1302
|
+
write_package_json({ "dependencies" => { "shadcn" => "1.0.4" } })
|
|
1303
|
+
output = capture_stdout { build(default_args).check_shadcn_setup }
|
|
1304
|
+
expect(output).not_to include("not regression-tested")
|
|
1305
|
+
end
|
|
1306
|
+
|
|
1307
|
+
it "WARNS (does not abort) when the installed major is outside the list", :aggregate_failures do
|
|
1308
|
+
write_package_json({ "dependencies" => { "shadcn" => "^3.0.0" } })
|
|
1309
|
+
output = capture_stdout { build(default_args).check_shadcn_setup }
|
|
1310
|
+
expect(output).to include("shadcn v3 is not regression-tested")
|
|
1311
|
+
expect(output).to include("tested majors: 1, 2")
|
|
1312
|
+
expect(output).to include("scaffold.md#shadcnui-setup")
|
|
1313
|
+
# the override points to the Ruact.configure block, NOT the freeze-blocked
|
|
1314
|
+
# direct mutation of Ruact.config (Codex R1)
|
|
1315
|
+
expect(output).to include("Ruact.configure { |c| c.shadcn_compatible_versions")
|
|
1316
|
+
expect(output).not_to include("set Ruact.config.shadcn_compatible_versions")
|
|
1317
|
+
# a warning, never a hard stop — the scaffold still writes
|
|
1318
|
+
expect { run_full_scaffold }.not_to raise_error
|
|
1319
|
+
expect(File).to exist(File.join(app_root, "app/controllers/posts_controller.rb"))
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
it "WARNS for the legacy shadcn-ui 0.x package name (out of range)" do
|
|
1323
|
+
write_package_json({ "devDependencies" => { "shadcn-ui" => "^0.8.0" } })
|
|
1324
|
+
output = capture_stdout { build(default_args).check_shadcn_setup }
|
|
1325
|
+
expect(output).to include("shadcn v0 is not regression-tested")
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1328
|
+
it "emits a SOFT NOTE (not a warning) when the version cannot be determined (npx)", :aggregate_failures do
|
|
1329
|
+
# no package.json — shadcn was run via npx, no pin
|
|
1330
|
+
output = capture_stdout { build(default_args).check_shadcn_setup }
|
|
1331
|
+
expect(output).to include("could not determine the installed shadcn version")
|
|
1332
|
+
expect(output).not_to include("not regression-tested")
|
|
1333
|
+
end
|
|
1334
|
+
|
|
1335
|
+
it "reads the major best-effort from both deps and devDeps (≥2 majors at the detection level)",
|
|
1336
|
+
:aggregate_failures do
|
|
1337
|
+
write_package_json({ "dependencies" => { "shadcn" => "^2.3.0" } })
|
|
1338
|
+
expect(build(default_args).installed_shadcn_major).to eq(2)
|
|
1339
|
+
write_package_json({ "devDependencies" => { "shadcn-ui" => "1.9.0" } })
|
|
1340
|
+
expect(build(default_args).installed_shadcn_major).to eq(1)
|
|
1341
|
+
File.write(File.join(app_root, "package.json"), "{ not valid json")
|
|
1342
|
+
expect(build(default_args).installed_shadcn_major).to be_nil
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
it "honors a custom Ruact.config.shadcn_compatible_versions override" do
|
|
1346
|
+
Ruact.configure { |c| c.shadcn_compatible_versions = [1, 2, 3] }
|
|
1347
|
+
write_package_json({ "dependencies" => { "shadcn" => "^3.0.0" } })
|
|
1348
|
+
output = capture_stdout { build(default_args).check_shadcn_setup }
|
|
1349
|
+
expect(output).not_to include("not regression-tested")
|
|
1350
|
+
end
|
|
1351
|
+
end
|
|
1352
|
+
|
|
1353
|
+
describe "dep-free invariant — never @tanstack / data-table in printed guidance (AC7)" do
|
|
1354
|
+
it "the missing-setup guidance contains neither @tanstack nor data-table", :aggregate_failures do
|
|
1355
|
+
gen = build(%w[Post title:string body:text published:boolean author:references])
|
|
1356
|
+
expect(gen.missing_shadcn_message).not_to include("@tanstack")
|
|
1357
|
+
expect(gen.missing_shadcn_message).not_to include("data-table")
|
|
1358
|
+
end
|
|
1359
|
+
|
|
1360
|
+
it "the partial-setup guidance contains neither @tanstack nor data-table", :aggregate_failures do
|
|
1361
|
+
write_components_json
|
|
1362
|
+
write_ui_components([])
|
|
1363
|
+
gen = build(%w[Post title:string body:text published:boolean author:references])
|
|
1364
|
+
expect(gen.partial_shadcn_message).not_to include("@tanstack")
|
|
1365
|
+
expect(gen.partial_shadcn_message).not_to include("data-table")
|
|
1366
|
+
end
|
|
1367
|
+
end
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1370
|
+
# ---------------------------------------------------------------------------
|
|
1371
|
+
# Story 14.3 (FR102) — the scaffold delegates model + migration + the
|
|
1372
|
+
# `resources` route + host-framework test stubs to Rails' own public
|
|
1373
|
+
# `resource` generator, THEN overlays the ruact layer. These specs STUB the
|
|
1374
|
+
# `invoke_rails_resource` seam: a bare `Dir.mktmpdir` is not a booted Rails
|
|
1375
|
+
# app (no `config.generators` hooks, no ActiveRecord migration machinery), so
|
|
1376
|
+
# a real `invoke "resource"` cannot run here — exactly as Story 14.1 stubs
|
|
1377
|
+
# `run_npm_install`. The REAL end-to-end delegation (real model/migration/test
|
|
1378
|
+
# files on disk, then `rails db:migrate`, then a live CRUD round-trip) is
|
|
1379
|
+
# proven by Story 14.6's clean-room Docker E2E, not by these unit specs.
|
|
1380
|
+
# ---------------------------------------------------------------------------
|
|
1381
|
+
describe "delegates to Rails `resource`, then overlays (Story 14.3 — FR102)", :story_14_3 do
|
|
1382
|
+
describe "the delegation seam (AC1, AC5)" do
|
|
1383
|
+
it "invokes invoke_rails_resource exactly once with the resource name + raw field:type args" do
|
|
1384
|
+
gen = build(%w[Post title:string body:text published:boolean])
|
|
1385
|
+
allow(gen).to receive(:invoke_rails_resource)
|
|
1386
|
+
|
|
1387
|
+
silently { gen.generate_rails_resource }
|
|
1388
|
+
|
|
1389
|
+
expect(gen).to have_received(:invoke_rails_resource)
|
|
1390
|
+
.with("Post", "title:string", "body:text", "published:boolean").once
|
|
1391
|
+
end
|
|
1392
|
+
|
|
1393
|
+
it "passes the RAW field:type strings through, not reconstructed view-models (modifiers survive)" do
|
|
1394
|
+
# `title:string{80}` / `author:references{polymorphic}` carry modifiers
|
|
1395
|
+
# (column limit, polymorphic ref) that ruact's ScaffoldAttribute
|
|
1396
|
+
# view-models DROP (`scaffold_attributes` strips everything after `{`).
|
|
1397
|
+
# The seam must receive the args VERBATIM so Rails parses the correct
|
|
1398
|
+
# migration columns — asserting the `{...}` payload reaches the seam is
|
|
1399
|
+
# what makes this non-vacuous (a reconstruct-from-view-models regression
|
|
1400
|
+
# would surface here as the bare `title:string` / `author:references`).
|
|
1401
|
+
gen = build(%w[Post title:string{80} author:references{polymorphic}])
|
|
1402
|
+
allow(gen).to receive(:invoke_rails_resource)
|
|
1403
|
+
|
|
1404
|
+
silently { gen.generate_rails_resource }
|
|
1405
|
+
|
|
1406
|
+
expect(gen).to have_received(:invoke_rails_resource)
|
|
1407
|
+
.with("Post", "title:string{80}", "author:references{polymorphic}")
|
|
1408
|
+
end
|
|
1409
|
+
|
|
1410
|
+
it "delegates through the PUBLIC resource generator surface (Thor invoke), no private API", :aggregate_failures do
|
|
1411
|
+
gen = build(%w[Post title:string])
|
|
1412
|
+
allow(gen).to receive(:invoke)
|
|
1413
|
+
|
|
1414
|
+
gen.send(:invoke_rails_resource, "Post", "title:string")
|
|
1415
|
+
|
|
1416
|
+
expect(gen).to have_received(:invoke).with("resource", %w[Post title:string])
|
|
1417
|
+
end
|
|
1418
|
+
end
|
|
1419
|
+
|
|
1420
|
+
describe "ordering — delegation runs BEFORE the overlay (AC1, AC5)" do
|
|
1421
|
+
# Thor runs public command methods in SOURCE definition order, so the
|
|
1422
|
+
# delegation winning-by-precedence reduces to a source-line assertion
|
|
1423
|
+
# (mirrors install_generator_spec's npm-step ordering check).
|
|
1424
|
+
it "defines generate_rails_resource before every overlay task", :aggregate_failures do
|
|
1425
|
+
line = ->(name) { described_class.instance_method(name).source_location.last }
|
|
1426
|
+
%i[create_controller add_resource_route create_queries create_views
|
|
1427
|
+
create_components create_smoke_spec].each do |overlay|
|
|
1428
|
+
expect(line.call(:generate_rails_resource)).to be < line.call(overlay),
|
|
1429
|
+
"expected generate_rails_resource before #{overlay}"
|
|
1430
|
+
end
|
|
1431
|
+
end
|
|
1432
|
+
|
|
1433
|
+
it "runs the delegation before the overlay controller write at call time" do
|
|
1434
|
+
gen = build(%w[Post title:string body:text published:boolean])
|
|
1435
|
+
order = []
|
|
1436
|
+
allow(gen).to receive(:invoke_rails_resource) { order << :resource }
|
|
1437
|
+
allow(gen).to receive(:template) { order << :controller }
|
|
1438
|
+
|
|
1439
|
+
silently do
|
|
1440
|
+
gen.generate_rails_resource
|
|
1441
|
+
gen.create_controller
|
|
1442
|
+
end
|
|
1443
|
+
|
|
1444
|
+
expect(order).to eq(%i[resource controller])
|
|
1445
|
+
end
|
|
1446
|
+
|
|
1447
|
+
# Story 14.5 (D1) — `check_shadcn_setup` is RE-PROMOTED to a Thor command
|
|
1448
|
+
# declared FIRST (before the delegation), so under `--shadcn` a missing-setup
|
|
1449
|
+
# abort precedes the delegation (zero partial state). On the default path it
|
|
1450
|
+
# no-ops (its `return unless shadcn?` guard).
|
|
1451
|
+
it "is declared before generate_rails_resource so a --shadcn missing-setup abort prevents delegation" do
|
|
1452
|
+
line = ->(name) { described_class.instance_method(name).source_location.last }
|
|
1453
|
+
expect(line.call(:check_shadcn_setup)).to be < line.call(:generate_rails_resource)
|
|
1454
|
+
end
|
|
1455
|
+
end
|
|
1456
|
+
|
|
1457
|
+
describe "controller overlay force-overwrites resource's bare controller (AC4, D2)" do
|
|
1458
|
+
it "writes the v2 controller with force, beating an existing bare controller — no prompt", :aggregate_failures do
|
|
1459
|
+
run_scaffold # seeds an existing controller on disk
|
|
1460
|
+
File.write(File.join(app_root, "app/controllers/posts_controller.rb"),
|
|
1461
|
+
"class PostsController < ApplicationController\nend\n") # resource's bare shape
|
|
1462
|
+
|
|
1463
|
+
gen = build(%w[Post title:string body:text published:boolean]) # no --force flag
|
|
1464
|
+
silently { gen.create_controller }
|
|
1465
|
+
|
|
1466
|
+
controller = read("app/controllers/posts_controller.rb")
|
|
1467
|
+
expect(controller).to include("include Ruact::Server")
|
|
1468
|
+
expect(controller).to include("def post_params")
|
|
1469
|
+
end
|
|
1470
|
+
end
|
|
1471
|
+
|
|
1472
|
+
describe "route + query mount reconciliation (AC4, D3)" do
|
|
1473
|
+
it "keeps exactly one resources :posts line (guard no-ops on the drawn line)" do
|
|
1474
|
+
# Simulate resource having already drawn the route, then run the overlay.
|
|
1475
|
+
File.write(File.join(app_root, "config/routes.rb"),
|
|
1476
|
+
"Rails.application.routes.draw do\n resources :posts\nend\n")
|
|
1477
|
+
gen = build(%w[Post title:string body:text published:boolean])
|
|
1478
|
+
silently do
|
|
1479
|
+
gen.add_resource_route
|
|
1480
|
+
gen.add_query_route
|
|
1481
|
+
end
|
|
1482
|
+
routes = read("config/routes.rb")
|
|
1483
|
+
expect(routes.scan("resources :posts").size).to eq(1)
|
|
1484
|
+
expect(routes).to include("ruact_queries PostsQuery")
|
|
1485
|
+
end
|
|
1486
|
+
end
|
|
1487
|
+
|
|
1488
|
+
describe "request-spec template drops the manual `rails g model` prerequisite (AC3)" do
|
|
1489
|
+
before { run_scaffold }
|
|
1490
|
+
|
|
1491
|
+
let(:spec_file) { read("spec/requests/posts_spec.rb") }
|
|
1492
|
+
|
|
1493
|
+
it "no longer instructs the developer to rails generate model / db:migrate by hand", :aggregate_failures do
|
|
1494
|
+
expect(spec_file).not_to include("rails generate model")
|
|
1495
|
+
expect(spec_file).not_to include("rails g model")
|
|
1496
|
+
expect(spec_file).not_to match(/^\s*#\s*rails db:migrate/)
|
|
1497
|
+
end
|
|
1498
|
+
|
|
1499
|
+
it "states the model + migration are generated by delegation to resource (FR102)" do
|
|
1500
|
+
expect(spec_file).to include("delegates them to Rails' own `resource` generator")
|
|
1501
|
+
end
|
|
1502
|
+
end
|
|
1503
|
+
|
|
1504
|
+
describe "smoke spec is framework-gated (AC2, D4)" do
|
|
1505
|
+
it "emits ruact's RSpec smoke spec when the host framework is RSpec" do
|
|
1506
|
+
gen = build(%w[Post title:string body:text published:boolean])
|
|
1507
|
+
allow(gen).to receive(:host_test_framework).and_return(:rspec)
|
|
1508
|
+
silently { gen.create_smoke_spec }
|
|
1509
|
+
expect(File).to exist(File.join(app_root, "spec/requests/posts_spec.rb"))
|
|
1510
|
+
end
|
|
1511
|
+
|
|
1512
|
+
it "does NOT write the RSpec smoke spec under a Minitest host (no broken rails_helper file)",
|
|
1513
|
+
:aggregate_failures do
|
|
1514
|
+
gen = build(%w[Post title:string body:text published:boolean])
|
|
1515
|
+
allow(gen).to receive(:host_test_framework).and_return(:test_unit)
|
|
1516
|
+
silently { gen.create_smoke_spec }
|
|
1517
|
+
expect(File).not_to exist(File.join(app_root, "spec/requests/posts_spec.rb"))
|
|
1518
|
+
end
|
|
1519
|
+
|
|
1520
|
+
it "defaults to emitting when the framework is undeterminable (no booted app — the unit harness)" do
|
|
1521
|
+
gen = build(%w[Post title:string body:text published:boolean])
|
|
1522
|
+
allow(gen).to receive(:host_test_framework).and_return(nil)
|
|
1523
|
+
silently { gen.create_smoke_spec }
|
|
1524
|
+
expect(File).to exist(File.join(app_root, "spec/requests/posts_spec.rb"))
|
|
1525
|
+
end
|
|
1526
|
+
|
|
1527
|
+
it "force-overwrites a same-path RSpec request stub (single request spec — AC4)" do
|
|
1528
|
+
# Simulate resource's RSpec request stub already at ruact's path.
|
|
1529
|
+
FileUtils.mkdir_p(File.join(app_root, "spec/requests"))
|
|
1530
|
+
File.write(File.join(app_root, "spec/requests/posts_spec.rb"),
|
|
1531
|
+
"# resource's empty stub\nRSpec.describe \"Posts\", type: :request do\nend\n")
|
|
1532
|
+
gen = build(%w[Post title:string body:text published:boolean]) # no --force flag
|
|
1533
|
+
silently { gen.create_smoke_spec }
|
|
1534
|
+
spec_file = read("spec/requests/posts_spec.rb")
|
|
1535
|
+
expect(spec_file).to include(%("Accept" => "application/json"))
|
|
1536
|
+
expect(spec_file).not_to include("resource's empty stub")
|
|
1537
|
+
end
|
|
1538
|
+
end
|
|
1539
|
+
end
|
|
1540
|
+
|
|
1541
|
+
# ---------------------------------------------------------------------------
|
|
1542
|
+
# Story 14.4 (FR103) — the DEFAULT scaffold emits design-system-AGNOSTIC
|
|
1543
|
+
# components (plain native HTML, Rails-default CSS, no shadcn/ui, no Tailwind)
|
|
1544
|
+
# and the mandatory shadcn pre-flight no longer aborts the default path. The
|
|
1545
|
+
# shadcn machinery (ShadcnPreflight, --skip-shadcn-check, the shadcn templates,
|
|
1546
|
+
# the 10.x shadcn coverage) is preserved DORMANT for Story 14.5's --shadcn
|
|
1547
|
+
# opt-in. These specs are the gem CI gate (AC5/AC7).
|
|
1548
|
+
# ---------------------------------------------------------------------------
|
|
1549
|
+
describe "agnostic default components + no mandatory pre-flight (Story 14.4 — FR103)", :story_14_4 do
|
|
1550
|
+
# The canonical model exercises every control kind (text, textarea, checkbox,
|
|
1551
|
+
# number, date, datetime, references → native <select>). It is also the model
|
|
1552
|
+
# the committed agnostic type-test fixtures are generated from.
|
|
1553
|
+
let(:canonical) do
|
|
1554
|
+
%w[Post title:string body:text published:boolean views:integer
|
|
1555
|
+
published_on:date published_at:datetime author:references]
|
|
1556
|
+
end
|
|
1557
|
+
|
|
1558
|
+
describe "AC1/AC3 — fresh app, no shadcn → completes, no pre-flight command, no abort" do
|
|
1559
|
+
# Story 14.5 (D1) — 14.5 re-promoted `check_shadcn_setup` to a Thor command,
|
|
1560
|
+
# so the 14.4 de-registration assertion is necessarily inverted. The 14.4
|
|
1561
|
+
# CONTRACT it guarded — the default path runs no pre-flight and never aborts —
|
|
1562
|
+
# is preserved by the command's `return unless shadcn?` runtime guard (proven
|
|
1563
|
+
# here by the no-raise no-flag invocation) rather than by de-registration.
|
|
1564
|
+
it "runs no pre-flight / never aborts on the default path (the --shadcn-gated command no-ops)",
|
|
1565
|
+
:aggregate_failures do
|
|
1566
|
+
expect { silently { build(%w[Post title:string]).check_shadcn_setup } }.not_to raise_error
|
|
1567
|
+
expect(described_class.commands).to have_key("create_components")
|
|
1568
|
+
expect(described_class.commands).to have_key("generate_rails_resource")
|
|
1569
|
+
expect(build(%w[Post title:string]).respond_to?(:check_shadcn_setup)).to be(true)
|
|
1570
|
+
end
|
|
1571
|
+
|
|
1572
|
+
it "preserves the dormant ShadcnPreflight machinery + --skip-shadcn-check option (D3)", :aggregate_failures do
|
|
1573
|
+
# The pre-flight helpers stay mixed in (read-only, never invoked); the
|
|
1574
|
+
# bypass option stays declared. Story 14.5 re-gates them behind --shadcn.
|
|
1575
|
+
gen = build(%w[Post title:string])
|
|
1576
|
+
expect(gen).to respond_to(:run_shadcn_preflight!)
|
|
1577
|
+
expect(gen).to respond_to(:required_shadcn_components)
|
|
1578
|
+
expect(described_class.class_options).to have_key(:skip_shadcn_check)
|
|
1579
|
+
end
|
|
1580
|
+
|
|
1581
|
+
it "scaffolds a fresh app with NO components.json / ui/* present — no abort, all three components written",
|
|
1582
|
+
:aggregate_failures do
|
|
1583
|
+
# The before-hook tmpdir has only config/routes.rb — no shadcn anywhere.
|
|
1584
|
+
expect { run_scaffold(canonical) }.not_to raise_error
|
|
1585
|
+
%w[PostList PostForm PostDeleteDialog].each do |name|
|
|
1586
|
+
expect(File).to exist(File.join(app_root, "app/javascript/components/#{name}.tsx"))
|
|
1587
|
+
end
|
|
1588
|
+
end
|
|
1589
|
+
end
|
|
1590
|
+
|
|
1591
|
+
describe "AC1/AC5 — the default output imports NO @/components/ui path (grep gate)" do
|
|
1592
|
+
before { run_scaffold(canonical) }
|
|
1593
|
+
|
|
1594
|
+
it "none of the three rendered component bodies references @/components/ui or Tailwind utilities",
|
|
1595
|
+
:aggregate_failures do
|
|
1596
|
+
%w[PostList PostForm PostDeleteDialog].each do |name|
|
|
1597
|
+
body = read("app/javascript/components/#{name}.tsx")
|
|
1598
|
+
expect(body).not_to include("@/components/ui"),
|
|
1599
|
+
"#{name}.tsx still imports a shadcn @/components/ui path"
|
|
1600
|
+
# no shadcn primitive elements leak into the agnostic markup
|
|
1601
|
+
expect(body).not_to include("<Table>")
|
|
1602
|
+
expect(body).not_to include("<Switch")
|
|
1603
|
+
expect(body).not_to include("<AlertDialog")
|
|
1604
|
+
expect(body).not_to include("<DropdownMenu")
|
|
1605
|
+
# no Tailwind utility classes (Tailwind is absent in a bare rails new app)
|
|
1606
|
+
expect(body).not_to include('className="')
|
|
1607
|
+
end
|
|
1608
|
+
end
|
|
1609
|
+
|
|
1610
|
+
it "List uses a native <table> + native search/sort/actions, keeping the data flow (AC2)",
|
|
1611
|
+
:aggregate_failures do
|
|
1612
|
+
list = read("app/javascript/components/PostList.tsx")
|
|
1613
|
+
# native table primitives, not shadcn
|
|
1614
|
+
expect(list).to include("<table>")
|
|
1615
|
+
expect(list).to include("<thead>")
|
|
1616
|
+
expect(list).to include("<tbody>")
|
|
1617
|
+
# the data flow is preserved verbatim: server-functions imports + useQuery
|
|
1618
|
+
expect(list).to include(
|
|
1619
|
+
'import { search as searchPosts, destroyPost, useQuery } from "@/.ruact/server-functions"'
|
|
1620
|
+
)
|
|
1621
|
+
expect(list).to include("useQuery<PostRow[]>(searchPosts, { q: q.trim() })")
|
|
1622
|
+
expect(list).to include("function compareRows(")
|
|
1623
|
+
# per-row Edit link + native Delete button driving the controlled dialog
|
|
1624
|
+
expect(list).to include("<a href={`/posts/${record.id}/edit`}>Edit</a>")
|
|
1625
|
+
expect(list).to include('<button type="button" onClick={() => setOpen(true)}>')
|
|
1626
|
+
expect(list).to include("<PostDeleteDialog")
|
|
1627
|
+
# boolean cell → plain "Yes"/"No" text (no <Badge>)
|
|
1628
|
+
expect(list).to include('<td>{row.published ? "Yes" : "No"}</td>')
|
|
1629
|
+
expect(list).not_to include("<Badge")
|
|
1630
|
+
end
|
|
1631
|
+
|
|
1632
|
+
it "Form binds every attribute type to a native control with inline FR98 errors (AC2)",
|
|
1633
|
+
:aggregate_failures do
|
|
1634
|
+
form = read("app/javascript/components/PostForm.tsx")
|
|
1635
|
+
expect(form).to match(/<input\s+id="title"\s+type="text"/m)
|
|
1636
|
+
expect(form).to match(/<textarea\s+id="body"/m)
|
|
1637
|
+
expect(form).to include('type="checkbox"')
|
|
1638
|
+
expect(form).to include("onChange={(e) => setPublished(e.target.checked)}")
|
|
1639
|
+
expect(form).to match(/<input\s+id="views"\s+type="number"/m)
|
|
1640
|
+
expect(form).to match(/<input\s+id="published_on"\s+type="date"/m)
|
|
1641
|
+
expect(form).to match(/<input\s+id="published_at"\s+type="datetime-local"/m)
|
|
1642
|
+
# references → native <select> over the controller-provided options prop
|
|
1643
|
+
expect(form).to include("<select")
|
|
1644
|
+
expect(form).to include("{authorOptions.map((option) => (")
|
|
1645
|
+
expect(form).to include("<option key={option.id} value={String(option.id)}>")
|
|
1646
|
+
# the FR98 keyed errors round-trip is preserved
|
|
1647
|
+
expect(form).to include("const [errors, setErrors] = useState<Record<string, string[]>>({});")
|
|
1648
|
+
%w[title body published views author_id].each do |col|
|
|
1649
|
+
expect(form).to include(%(errorsFor("#{col}")))
|
|
1650
|
+
end
|
|
1651
|
+
end
|
|
1652
|
+
|
|
1653
|
+
it "DeleteDialog is a controlled native <dialog> preserving the { ok, error? } contract (AC2)",
|
|
1654
|
+
:aggregate_failures do
|
|
1655
|
+
dialog = read("app/javascript/components/PostDeleteDialog.tsx")
|
|
1656
|
+
expect(dialog).to include("<dialog ref={dialogRef}")
|
|
1657
|
+
expect(dialog).to include("node.showModal();")
|
|
1658
|
+
expect(dialog).to include("node.close();")
|
|
1659
|
+
# the controlled contract survives unchanged
|
|
1660
|
+
expect(dialog).to include("open: boolean;")
|
|
1661
|
+
expect(dialog).to include("onOpenChange: (open: boolean) => void;")
|
|
1662
|
+
expect(dialog).to include("onConfirm: () => Promise<{ ok: boolean; error?: string }>;")
|
|
1663
|
+
expect(dialog).to include("const res = await onConfirm();")
|
|
1664
|
+
expect(dialog).to include('setError(res.error ?? "Could not delete this post")')
|
|
1665
|
+
# the title copy + cannot-be-undone body, on native elements
|
|
1666
|
+
expect(dialog).to include('<h2>Delete “{String(post.title ?? "")}”?</h2>')
|
|
1667
|
+
expect(dialog).to include("This action cannot be undone.")
|
|
1668
|
+
# no native <form method="dialog"> (would race onConfirm); buttons only
|
|
1669
|
+
expect(dialog).not_to include("<form")
|
|
1670
|
+
expect(dialog).not_to include('method="')
|
|
1671
|
+
end
|
|
1672
|
+
end
|
|
1673
|
+
|
|
1674
|
+
describe "AC4 — FR99/FR100 preserved in the agnostic .tsx; --javascript stays untyped .jsx" do
|
|
1675
|
+
it "the typed .tsx carries type <Model>Row + __ruactContract on all three", :aggregate_failures do
|
|
1676
|
+
run_scaffold(canonical)
|
|
1677
|
+
%w[PostList PostForm PostDeleteDialog].each do |name|
|
|
1678
|
+
body = read("app/javascript/components/#{name}.tsx")
|
|
1679
|
+
expect(body).to start_with(%("use client";))
|
|
1680
|
+
expect(body).to include("type PostRow = {")
|
|
1681
|
+
expect(body).to include("export const __ruactContract")
|
|
1682
|
+
end
|
|
1683
|
+
# the contract prop sets, exactly as the shadcn variants declared
|
|
1684
|
+
expect(read("app/javascript/components/PostList.tsx")).to include('posts: "required"')
|
|
1685
|
+
expect(read("app/javascript/components/PostForm.tsx")).to include('initial: "optional"')
|
|
1686
|
+
expect(read("app/javascript/components/PostDeleteDialog.tsx")).to include('post: "required"')
|
|
1687
|
+
end
|
|
1688
|
+
|
|
1689
|
+
it "--javascript strips the FR99 type + FR100 contract and emits the forfeits banner", :aggregate_failures do
|
|
1690
|
+
run_scaffold(%w[Post title:string body:text published:boolean author:references], javascript: true)
|
|
1691
|
+
%w[PostList PostForm PostDeleteDialog].each do |name|
|
|
1692
|
+
expect(File).to exist(File.join(app_root, "app/javascript/components/#{name}.jsx"))
|
|
1693
|
+
expect(File).not_to exist(File.join(app_root, "app/javascript/components/#{name}.tsx"))
|
|
1694
|
+
body = read("app/javascript/components/#{name}.jsx")
|
|
1695
|
+
expect(body).not_to include("__ruactContract")
|
|
1696
|
+
expect(body).not_to include("type PostRow")
|
|
1697
|
+
expect(body).to include("forfeits")
|
|
1698
|
+
# still agnostic — no shadcn even in .jsx
|
|
1699
|
+
expect(body).not_to include("@/components/ui")
|
|
1700
|
+
end
|
|
1701
|
+
end
|
|
1702
|
+
end
|
|
1703
|
+
|
|
1704
|
+
describe "AC5 — the agnostic fixtures stay byte-identical to the generator's live output" do
|
|
1705
|
+
# The committed fixtures under type-tests/scaffold/agnostic/ are what
|
|
1706
|
+
# `npm run typecheck` type-checks against the agnostic ambient stub (NO
|
|
1707
|
+
# @/components/ui module). If the generator drifts, the typecheck would be
|
|
1708
|
+
# proving stale code — so assert byte-equality, mirroring the 10.x pattern.
|
|
1709
|
+
%w[PostList PostForm PostDeleteDialog].each do |name|
|
|
1710
|
+
it "#{name}.tsx render stays byte-identical to the committed agnostic fixture" do
|
|
1711
|
+
run_scaffold(canonical)
|
|
1712
|
+
generated = read("app/javascript/components/#{name}.tsx")
|
|
1713
|
+
fixture_path = "vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/#{name}.tsx"
|
|
1714
|
+
fixture = File.read(File.expand_path("../../#{fixture_path}", __dir__))
|
|
1715
|
+
|
|
1716
|
+
expect(generated).to eq(fixture),
|
|
1717
|
+
"Generated #{name}.tsx drifted from the agnostic type-test fixture. " \
|
|
1718
|
+
"Regenerate it: rails g ruact:scaffold Post title:string body:text " \
|
|
1719
|
+
"published:boolean views:integer published_on:date published_at:datetime " \
|
|
1720
|
+
"author:references → copy app/javascript/components/#{name}.tsx over " \
|
|
1721
|
+
"#{fixture_path}, then re-run `npm run typecheck`."
|
|
1722
|
+
end
|
|
1723
|
+
end
|
|
1724
|
+
end
|
|
1725
|
+
end
|
|
1726
|
+
|
|
1727
|
+
# ---------------------------------------------------------------------------
|
|
1728
|
+
# Story 14.5 (FR103b) — `--shadcn` is the explicit opt-in to the Epic 10 shadcn
|
|
1729
|
+
# design system. It flips ONLY the component templates + re-activates the
|
|
1730
|
+
# dependency pre-flight; everything else (controller, route, query, views,
|
|
1731
|
+
# smoke spec) is identical to the agnostic default. This block proves the FLAG
|
|
1732
|
+
# MATRIX orthogonally to the re-pointed Epic 10 coverage above: the
|
|
1733
|
+
# @/components/ui grep in BOTH directions, the FR99/FR100 preservation +
|
|
1734
|
+
# --javascript forfeit under --shadcn, and the --shadcn-gated pre-flight
|
|
1735
|
+
# abort (vs the no-abort default).
|
|
1736
|
+
# ---------------------------------------------------------------------------
|
|
1737
|
+
describe "shadcn is opt-in via --shadcn (Story 14.5 — FR103b)", :story_14_5 do
|
|
1738
|
+
let(:canonical) do
|
|
1739
|
+
%w[Post title:string body:text published:boolean views:integer
|
|
1740
|
+
published_on:date published_at:datetime author:references]
|
|
1741
|
+
end
|
|
1742
|
+
|
|
1743
|
+
describe "AC1/AC4 — --shadcn emits the Epic 10 shadcn components (imports @/components/ui)" do
|
|
1744
|
+
before { run_scaffold(canonical, shadcn: true) }
|
|
1745
|
+
|
|
1746
|
+
it "all three --shadcn components import @/components/ui (inverse of the agnostic grep)",
|
|
1747
|
+
:aggregate_failures do
|
|
1748
|
+
%w[PostList PostForm PostDeleteDialog].each do |name|
|
|
1749
|
+
body = read("app/javascript/components/#{name}.tsx")
|
|
1750
|
+
expect(body).to include("@/components/ui"), "#{name}.tsx (--shadcn) should import @/components/ui"
|
|
1751
|
+
end
|
|
1752
|
+
end
|
|
1753
|
+
|
|
1754
|
+
it "carries the shadcn primitive markers (Table / form controls / AlertDialog)", :aggregate_failures do
|
|
1755
|
+
list = read("app/javascript/components/PostList.tsx")
|
|
1756
|
+
form = read("app/javascript/components/PostForm.tsx")
|
|
1757
|
+
dialog = read("app/javascript/components/PostDeleteDialog.tsx")
|
|
1758
|
+
expect(list).to include("<Table>")
|
|
1759
|
+
expect(list).to include('from "@/components/ui/table"')
|
|
1760
|
+
expect(form).to include('import { Input } from "@/components/ui/input"')
|
|
1761
|
+
expect(form).to include('import { Switch } from "@/components/ui/switch"')
|
|
1762
|
+
expect(dialog).to include('from "@/components/ui/alert-dialog"')
|
|
1763
|
+
expect(dialog).to include("<AlertDialog ")
|
|
1764
|
+
end
|
|
1765
|
+
end
|
|
1766
|
+
|
|
1767
|
+
describe "AC2 — the no-flag default stays agnostic (regression guard)" do
|
|
1768
|
+
before { run_scaffold(canonical) }
|
|
1769
|
+
|
|
1770
|
+
it "none of the default components import @/components/ui", :aggregate_failures do
|
|
1771
|
+
%w[PostList PostForm PostDeleteDialog].each do |name|
|
|
1772
|
+
body = read("app/javascript/components/#{name}.tsx")
|
|
1773
|
+
expect(body).not_to include("@/components/ui"), "#{name}.tsx (default) must stay agnostic"
|
|
1774
|
+
end
|
|
1775
|
+
end
|
|
1776
|
+
end
|
|
1777
|
+
|
|
1778
|
+
describe "AC5 — FR99/FR100 preserved under --shadcn .tsx; --shadcn --javascript forfeits both" do
|
|
1779
|
+
it "the --shadcn .tsx carries type <Model>Row + __ruactContract on all three", :aggregate_failures do
|
|
1780
|
+
run_scaffold(canonical, shadcn: true)
|
|
1781
|
+
%w[PostList PostForm PostDeleteDialog].each do |name|
|
|
1782
|
+
body = read("app/javascript/components/#{name}.tsx")
|
|
1783
|
+
expect(body).to include("type PostRow = {")
|
|
1784
|
+
expect(body).to include("export const __ruactContract")
|
|
1785
|
+
end
|
|
1786
|
+
end
|
|
1787
|
+
|
|
1788
|
+
it "--shadcn --javascript emits untyped .jsx shadcn output (no type/contract, forfeits banner)",
|
|
1789
|
+
:aggregate_failures do
|
|
1790
|
+
run_scaffold(%w[Post title:string body:text published:boolean author:references],
|
|
1791
|
+
shadcn: true, javascript: true)
|
|
1792
|
+
%w[PostList PostForm PostDeleteDialog].each do |name|
|
|
1793
|
+
expect(File).to exist(File.join(app_root, "app/javascript/components/#{name}.jsx"))
|
|
1794
|
+
expect(File).not_to exist(File.join(app_root, "app/javascript/components/#{name}.tsx"))
|
|
1795
|
+
body = read("app/javascript/components/#{name}.jsx")
|
|
1796
|
+
expect(body).not_to include("type PostRow")
|
|
1797
|
+
expect(body).not_to include("__ruactContract")
|
|
1798
|
+
expect(body).to include("forfeits")
|
|
1799
|
+
# still shadcn even untyped
|
|
1800
|
+
expect(body).to include("@/components/ui")
|
|
1801
|
+
end
|
|
1802
|
+
end
|
|
1803
|
+
end
|
|
1804
|
+
|
|
1805
|
+
describe "AC3 — the pre-flight runs ONLY under --shadcn (abort vs no-op)" do
|
|
1806
|
+
it "aborts under --shadcn on a fresh app with no components.json (zero partial state)", :aggregate_failures do
|
|
1807
|
+
gen = build(%w[Post title:string body:text published:boolean], shadcn: true)
|
|
1808
|
+
silently do
|
|
1809
|
+
expect { gen.check_shadcn_setup }.to raise_error(Thor::Error, %r{shadcn/ui is not set up})
|
|
1810
|
+
end
|
|
1811
|
+
# the abort precedes any write
|
|
1812
|
+
expect(File).not_to exist(File.join(app_root, "app/javascript/components/PostList.tsx"))
|
|
1813
|
+
end
|
|
1814
|
+
|
|
1815
|
+
it "the default (no-flag) path never aborts on the same fresh app", :aggregate_failures do
|
|
1816
|
+
gen = build(%w[Post title:string body:text published:boolean])
|
|
1817
|
+
expect { silently { gen.check_shadcn_setup } }.not_to raise_error
|
|
1818
|
+
expect { silently { run_scaffold(%w[Post title:string body:text published:boolean]) } }.not_to raise_error
|
|
1819
|
+
end
|
|
1820
|
+
|
|
1821
|
+
it "--shadcn --skip-shadcn-check writes the shadcn components anyway, with the in-file banner",
|
|
1822
|
+
:aggregate_failures do
|
|
1823
|
+
# create_components renders the shadcn templates; shadcn_missing? (skip +
|
|
1824
|
+
# incomplete setup) re-surfaces the banner.
|
|
1825
|
+
run_scaffold(%w[Post title:string body:text published:boolean], shadcn: true, skip_shadcn_check: true)
|
|
1826
|
+
%w[PostList PostForm PostDeleteDialog].each do |name|
|
|
1827
|
+
component = read("app/javascript/components/#{name}.tsx")
|
|
1828
|
+
expect(component).to include("--skip-shadcn-check")
|
|
1829
|
+
expect(component).to include("@/components/ui/*")
|
|
1830
|
+
expect(component).to include("npx shadcn@latest add")
|
|
1831
|
+
end
|
|
1832
|
+
end
|
|
1833
|
+
end
|
|
1834
|
+
end
|
|
1835
|
+
end
|