ruact 0.0.4 → 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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +68 -17
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. 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