rails-ai-context 4.2.2 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,519 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Tools
5
+ class GenerateTest < BaseTool
6
+ tool_name "rails_generate_test"
7
+ description "Generate test scaffolding that matches your project's actual test patterns — framework, factories, assertion style. " \
8
+ "Use when: adding tests for a model, controller, or service. Generates copy-paste-ready test files. " \
9
+ "Key params: model (e.g. 'User'), controller (e.g. 'CooksController'), file (e.g. 'app/services/foo.rb')."
10
+
11
+ input_schema(
12
+ properties: {
13
+ model: {
14
+ type: "string",
15
+ description: "Model name (e.g. 'User'). Generates model spec with validations, associations, scopes, enums."
16
+ },
17
+ controller: {
18
+ type: "string",
19
+ description: "Controller name (e.g. 'CooksController'). Generates request spec with routes and auth."
20
+ },
21
+ file: {
22
+ type: "string",
23
+ description: "File path relative to Rails root (e.g. 'app/services/payment_service.rb'). Auto-detects type."
24
+ },
25
+ type: {
26
+ type: "string",
27
+ enum: %w[unit request system],
28
+ description: "Test type: unit (model/service, default), request (controller), system (browser/Capybara)."
29
+ }
30
+ }
31
+ )
32
+
33
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
34
+
35
+ def self.call(model: nil, controller: nil, file: nil, type: "unit", server_context: nil)
36
+ unless model || controller || file
37
+ return text_response("Provide at least one of: `model`, `controller`, or `file`.")
38
+ end
39
+
40
+ tests_data = cached_context[:tests] || {}
41
+ framework = tests_data[:framework] || detect_framework
42
+ patterns = detect_patterns(framework)
43
+
44
+ if model
45
+ generate_model_test(model.strip, framework, patterns, tests_data)
46
+ elsif controller
47
+ generate_controller_test(controller.strip, framework, patterns, tests_data)
48
+ elsif file
49
+ generate_file_test(file.strip, framework, patterns, tests_data, type)
50
+ end
51
+ rescue => e
52
+ text_response("Generate test error: #{e.message}")
53
+ end
54
+
55
+ class << self
56
+ private
57
+
58
+ def detect_framework
59
+ if Dir.exist?(File.join(Rails.root, "spec"))
60
+ "rspec"
61
+ else
62
+ "minitest"
63
+ end
64
+ end
65
+
66
+ # Scan existing tests to learn project patterns
67
+ def detect_patterns(framework) # rubocop:disable Metrics
68
+ root = Rails.root.to_s
69
+ patterns = { factory_style: :create, let_style: true, expect_style: true, described_class: true }
70
+
71
+ glob = framework == "rspec" ? "spec/**/*_spec.rb" : "test/**/*_test.rb"
72
+ files = Dir.glob(File.join(root, glob)).first(5)
73
+
74
+ expect_count = 0
75
+ should_count = 0
76
+ create_count = 0
77
+ build_count = 0
78
+ let_count = 0
79
+ instance_var_count = 0
80
+
81
+ files.each do |f|
82
+ next if File.size(f) > config.max_test_file_size
83
+ source = File.read(f, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
84
+ expect_count += source.scan(/expect\(/).size
85
+ should_count += source.scan(/\.should\b/).size
86
+ create_count += source.scan(/create\(:/).size
87
+ build_count += source.scan(/build\(:/).size
88
+ let_count += source.scan(/\blet[!]?\(:/).size
89
+ instance_var_count += source.scan(/@\w+\s*=/).size
90
+ end
91
+
92
+ patterns[:expect_style] = expect_count >= should_count
93
+ patterns[:factory_style] = create_count >= build_count ? :create : :build
94
+ patterns[:let_style] = let_count > instance_var_count
95
+ patterns
96
+ end
97
+
98
+ # ── Model test generation ────────────────────────────────────────
99
+
100
+ def generate_model_test(model_name, framework, patterns, tests_data)
101
+ models = cached_context[:models] || {}
102
+ key = models.keys.find { |k| k.downcase == model_name.downcase } ||
103
+ models.keys.find { |k| k.underscore == model_name.underscore }
104
+ unless key
105
+ return not_found_response("Model", model_name, models.keys.sort,
106
+ recovery_tool: "Call rails_get_model_details(detail:\"summary\") to see all models")
107
+ end
108
+
109
+ data = models[key]
110
+ return text_response("Model #{key} has errors: #{data[:error]}") if data[:error]
111
+
112
+ if framework == "rspec"
113
+ generate_rspec_model(key, data, patterns, tests_data)
114
+ else
115
+ generate_minitest_model(key, data, patterns, tests_data)
116
+ end
117
+ end
118
+
119
+ def generate_rspec_model(name, data, patterns, tests_data)
120
+ file_path = "spec/models/#{name.underscore}_spec.rb"
121
+ factory = find_factory_name(name, tests_data)
122
+ lines = []
123
+ lines << "# #{file_path}"
124
+ lines << ""
125
+ lines << "```ruby"
126
+ lines << "# frozen_string_literal: true"
127
+ lines << ""
128
+ lines << "require \"rails_helper\""
129
+ lines << ""
130
+ lines << "RSpec.describe #{name}, type: :model do"
131
+
132
+ # Factory/fixture setup
133
+ if factory
134
+ style = patterns[:factory_style]
135
+ if patterns[:let_style]
136
+ lines << " let(:#{name.underscore}) { #{style}(:#{factory}) }"
137
+ end
138
+ end
139
+
140
+ # Associations
141
+ assocs = data[:associations] || []
142
+ if assocs.any?
143
+ lines << ""
144
+ lines << " describe \"associations\" do"
145
+ assocs.each do |a|
146
+ case a[:type]
147
+ when "belongs_to"
148
+ lines << " it { is_expected.to belong_to(:#{a[:name]}) }"
149
+ when "has_many"
150
+ if a[:through]
151
+ lines << " it { is_expected.to have_many(:#{a[:name]}).through(:#{a[:through]}) }"
152
+ else
153
+ dep = a[:dependent] ? ".dependent(:#{a[:dependent]})" : ""
154
+ lines << " it { is_expected.to have_many(:#{a[:name]})#{dep} }"
155
+ end
156
+ when "has_one"
157
+ lines << " it { is_expected.to have_one(:#{a[:name]}) }"
158
+ end
159
+ end
160
+ lines << " end"
161
+ end
162
+
163
+ # Validations
164
+ validations = data[:validations] || []
165
+ if validations.any?
166
+ lines << ""
167
+ lines << " describe \"validations\" do"
168
+ seen = Set.new
169
+ validations.each do |v|
170
+ v[:attributes].each do |attr|
171
+ key = "#{v[:kind]}:#{attr}"
172
+ next if seen.include?(key)
173
+ seen << key
174
+
175
+ case v[:kind]
176
+ when "presence"
177
+ lines << " it { is_expected.to validate_presence_of(:#{attr}) }"
178
+ when "uniqueness"
179
+ lines << " it { is_expected.to validate_uniqueness_of(:#{attr}) }"
180
+ when "length"
181
+ opts = v[:options] || {}
182
+ matcher = "validate_length_of(:#{attr})"
183
+ matcher += ".is_at_most(#{opts[:maximum]})" if opts[:maximum]
184
+ matcher += ".is_at_least(#{opts[:minimum]})" if opts[:minimum]
185
+ lines << " it { is_expected.to #{matcher} }"
186
+ when "numericality"
187
+ lines << " it { is_expected.to validate_numericality_of(:#{attr}) }"
188
+ when "inclusion"
189
+ vals = v.dig(:options, :in)
190
+ if vals
191
+ lines << " it { is_expected.to validate_inclusion_of(:#{attr}).in_array(#{vals.inspect}) }"
192
+ else
193
+ lines << " it { is_expected.to validate_inclusion_of(:#{attr}) }"
194
+ end
195
+ else
196
+ lines << " it \"validates #{v[:kind]} of #{attr}\" do"
197
+ lines << " # TODO: implement #{v[:kind]} validation test"
198
+ lines << " end"
199
+ end
200
+ end
201
+ end
202
+ lines << " end"
203
+ end
204
+
205
+ # Scopes
206
+ scopes = data[:scopes] || []
207
+ if scopes.any?
208
+ lines << ""
209
+ lines << " describe \"scopes\" do"
210
+ scopes.each do |s|
211
+ scope_name = s.is_a?(Hash) ? s[:name] : s
212
+ lines << " describe \".#{scope_name}\" do"
213
+ lines << " it \"returns expected records\" do"
214
+ lines << " # TODO: create test data and verify scope behavior"
215
+ lines << " end"
216
+ lines << " end"
217
+ end
218
+ lines << " end"
219
+ end
220
+
221
+ # Enums
222
+ enums = data[:enums] || {}
223
+ if enums.any?
224
+ lines << ""
225
+ lines << " describe \"enums\" do"
226
+ enums.each do |attr, values|
227
+ vals = values.is_a?(Hash) ? values.keys : Array(values)
228
+ lines << " it { is_expected.to define_enum_for(:#{attr}).with_values(#{vals.inspect}) }"
229
+ end
230
+ lines << " end"
231
+ end
232
+
233
+ # Callbacks
234
+ callbacks = data[:callbacks] || {}
235
+ if callbacks.any?
236
+ lines << ""
237
+ lines << " describe \"callbacks\" do"
238
+ callbacks.each do |type, methods|
239
+ Array(methods).each do |m|
240
+ lines << " it \"#{type} calls #{m}\" do"
241
+ lines << " # TODO: verify callback behavior"
242
+ lines << " end"
243
+ end
244
+ end
245
+ lines << " end"
246
+ end
247
+
248
+ lines << "end"
249
+ lines << "```"
250
+
251
+ text_response(lines.join("\n"))
252
+ end
253
+
254
+ def generate_minitest_model(name, data, _patterns, tests_data)
255
+ file_path = "test/models/#{name.underscore}_test.rb"
256
+ factory = find_factory_name(name, tests_data)
257
+ lines = []
258
+ lines << "# #{file_path}"
259
+ lines << ""
260
+ lines << "```ruby"
261
+ lines << "# frozen_string_literal: true"
262
+ lines << ""
263
+ lines << "require \"test_helper\""
264
+ lines << ""
265
+ lines << "class #{name}Test < ActiveSupport::TestCase"
266
+
267
+ setup_var = factory ? "#{name.underscore}" : "#{name.underscore}"
268
+ if factory
269
+ lines << " setup do"
270
+ lines << " @#{setup_var} = create(:#{factory})"
271
+ lines << " end"
272
+ end
273
+
274
+ # Validations
275
+ validations = data[:validations] || []
276
+ if validations.any?
277
+ lines << ""
278
+ seen = Set.new
279
+ validations.each do |v|
280
+ v[:attributes].each do |attr|
281
+ key = "#{v[:kind]}:#{attr}"
282
+ next if seen.include?(key)
283
+ seen << key
284
+ lines << " test \"validates #{v[:kind]} of #{attr}\" do"
285
+ lines << " @#{setup_var}.#{attr} = nil" if v[:kind] == "presence"
286
+ lines << " assert_not @#{setup_var}.valid?"
287
+ lines << " end"
288
+ lines << ""
289
+ end
290
+ end
291
+ end
292
+
293
+ # Associations
294
+ assocs = data[:associations] || []
295
+ if assocs.any?
296
+ assocs.each do |a|
297
+ lines << " test \"#{a[:type]} #{a[:name]}\" do"
298
+ lines << " assert_respond_to @#{setup_var}, :#{a[:name]}"
299
+ lines << " end"
300
+ lines << ""
301
+ end
302
+ end
303
+
304
+ # Scopes
305
+ scopes = data[:scopes] || []
306
+ scopes.each do |s|
307
+ scope_name = s.is_a?(Hash) ? s[:name] : s
308
+ lines << " test \"scope .#{scope_name} returns expected records\" do"
309
+ lines << " # TODO: create test data and verify"
310
+ lines << " end"
311
+ lines << ""
312
+ end
313
+
314
+ lines << "end"
315
+ lines << "```"
316
+
317
+ text_response(lines.join("\n"))
318
+ end
319
+
320
+ # ── Controller test generation ───────────────────────────────────
321
+
322
+ def generate_controller_test(ctrl_name, framework, patterns, tests_data)
323
+ ctrl_name = ctrl_name.strip
324
+ # Normalize: "cooks" → "CooksController", "CooksController" stays
325
+ ctrl_class = ctrl_name.end_with?("Controller") ? ctrl_name : "#{ctrl_name.camelize}Controller"
326
+ snake = ctrl_class.underscore.delete_suffix("_controller")
327
+
328
+ routes = cached_context[:routes] || {}
329
+ by_ctrl = routes[:by_controller] || {}
330
+ ctrl_routes = by_ctrl[snake] || by_ctrl[snake.pluralize] || []
331
+
332
+ if framework == "rspec"
333
+ generate_rspec_request(ctrl_class, snake, ctrl_routes, patterns, tests_data)
334
+ else
335
+ generate_minitest_controller(ctrl_class, snake, ctrl_routes, tests_data)
336
+ end
337
+ end
338
+
339
+ def generate_rspec_request(ctrl_class, snake, routes, patterns, tests_data)
340
+ file_path = "spec/requests/#{snake}_spec.rb"
341
+ factory = find_factory_name(snake.singularize.camelize, tests_data)
342
+ has_devise = tests_data[:test_helper_setup]&.any? { |h| h.include?("Devise") }
343
+
344
+ lines = [ "# #{file_path}", "", "```ruby", "# frozen_string_literal: true", "", "require \"rails_helper\"", "" ]
345
+ lines << "RSpec.describe \"#{ctrl_class}\", type: :request do"
346
+
347
+ if has_devise
348
+ lines << " include Devise::Test::IntegrationHelpers"
349
+ lines << ""
350
+ lines << " let(:user) { create(:user) }"
351
+ lines << " before { sign_in user }"
352
+ end
353
+
354
+ if factory
355
+ lines << " let(:#{snake.singularize}) { create(:#{factory}) }"
356
+ end
357
+
358
+ routes.each do |r|
359
+ verb = (r[:verb] || "GET").downcase
360
+ path = r[:path] || "/#{snake}"
361
+ action = r[:action] || "index"
362
+ helper = r[:name] ? "#{r[:name]}_path" : "\"#{path}\""
363
+
364
+ lines << ""
365
+ lines << " describe \"#{r[:verb]} #{r[:path]}\" do"
366
+
367
+ case action
368
+ when "index"
369
+ lines << " it \"returns success\" do"
370
+ lines << " get #{helper}"
371
+ lines << " expect(response).to have_http_status(:ok)"
372
+ lines << " end"
373
+ when "show"
374
+ lines << " it \"returns the #{snake.singularize}\" do"
375
+ lines << " get #{helper}#{"(id: #{snake.singularize}.id)" if r[:name]}"
376
+ lines << " expect(response).to have_http_status(:ok)"
377
+ lines << " end"
378
+ when "create"
379
+ lines << " context \"with valid params\" do"
380
+ lines << " it \"creates a new #{snake.singularize}\" do"
381
+ lines << " expect {"
382
+ lines << " post #{helper}, params: { #{snake.singularize}: valid_attributes }"
383
+ lines << " }.to change(#{snake.singularize.camelize}, :count).by(1)"
384
+ lines << " end"
385
+ lines << " end"
386
+ lines << ""
387
+ lines << " context \"with invalid params\" do"
388
+ lines << " it \"does not create\" do"
389
+ lines << " expect {"
390
+ lines << " post #{helper}, params: { #{snake.singularize}: invalid_attributes }"
391
+ lines << " }.not_to change(#{snake.singularize.camelize}, :count)"
392
+ lines << " end"
393
+ lines << " end"
394
+ when "update"
395
+ lines << " it \"updates the #{snake.singularize}\" do"
396
+ lines << " patch #{helper}#{"(id: #{snake.singularize}.id)" if r[:name]}, params: { #{snake.singularize}: { name: \"Updated\" } }"
397
+ lines << " expect(response).to redirect_to(#{snake.singularize})"
398
+ lines << " end"
399
+ when "destroy"
400
+ lines << " it \"destroys the #{snake.singularize}\" do"
401
+ lines << " #{snake.singularize} # ensure exists"
402
+ lines << " expect {"
403
+ lines << " delete #{helper}#{"(id: #{snake.singularize}.id)" if r[:name]}"
404
+ lines << " }.to change(#{snake.singularize.camelize}, :count).by(-1)"
405
+ lines << " end"
406
+ else
407
+ lines << " it \"returns success\" do"
408
+ lines << " #{verb} #{helper}"
409
+ lines << " expect(response).to have_http_status(:ok)"
410
+ lines << " end"
411
+ end
412
+
413
+ lines << " end"
414
+ end
415
+
416
+ if routes.empty?
417
+ lines << " it \"has tests\" do"
418
+ lines << " # No routes found for #{ctrl_class}. Add route-specific tests here."
419
+ lines << " end"
420
+ end
421
+
422
+ lines << "end"
423
+ lines << "```"
424
+ text_response(lines.join("\n"))
425
+ end
426
+
427
+ def generate_minitest_controller(ctrl_class, snake, routes, tests_data)
428
+ file_path = "test/controllers/#{snake}_controller_test.rb"
429
+ lines = [ "# #{file_path}", "", "```ruby", "# frozen_string_literal: true", "", "require \"test_helper\"", "" ]
430
+ lines << "class #{ctrl_class}Test < ActionDispatch::IntegrationTest"
431
+
432
+ has_devise = tests_data[:test_helper_setup]&.any? { |h| h.include?("Devise") }
433
+ if has_devise
434
+ lines << " include Devise::Test::IntegrationHelpers"
435
+ lines << ""
436
+ lines << " setup do"
437
+ lines << " @user = users(:one)"
438
+ lines << " sign_in @user"
439
+ lines << " end"
440
+ end
441
+
442
+ routes.each do |r|
443
+ action = r[:action] || "index"
444
+ path = r[:path] || "/#{snake}"
445
+ verb = (r[:verb] || "GET").downcase
446
+
447
+ lines << ""
448
+ lines << " test \"#{r[:verb]} #{r[:path]} works\" do"
449
+ lines << " #{verb} #{path.gsub(/:(\w+)/, '#{\\1}')}"
450
+ lines << " assert_response :success"
451
+ lines << " end"
452
+ end
453
+
454
+ lines << "end"
455
+ lines << "```"
456
+ text_response(lines.join("\n"))
457
+ end
458
+
459
+ # ── File-based test generation ───────────────────────────────────
460
+
461
+ def generate_file_test(file, framework, patterns, tests_data, type)
462
+ case file
463
+ when %r{app/models/(.+)\.rb}
464
+ model_name = $1.split("/").last.camelize
465
+ generate_model_test(model_name, framework, patterns, tests_data)
466
+ when %r{app/controllers/(.+)_controller\.rb}
467
+ ctrl_name = "#{$1.split('/').map(&:camelize).join('::')}Controller"
468
+ generate_controller_test(ctrl_name, framework, patterns, tests_data)
469
+ when %r{app/services/(.+)\.rb}, %r{app/jobs/(.+)\.rb}
470
+ class_name = $1.split("/").map(&:camelize).join("::")
471
+ generate_service_test(class_name, file, framework)
472
+ else
473
+ text_response("Cannot auto-detect test type for `#{file}`. Use `model:` or `controller:` parameter instead.")
474
+ end
475
+ end
476
+
477
+ def generate_service_test(class_name, file, framework)
478
+ if framework == "rspec"
479
+ path = "spec/services/#{class_name.underscore}_spec.rb"
480
+ lines = [ "# #{path}", "", "```ruby", "# frozen_string_literal: true", "", "require \"rails_helper\"", "" ]
481
+ lines << "RSpec.describe #{class_name} do"
482
+ lines << " describe \".call\" do"
483
+ lines << " it \"performs the expected action\" do"
484
+ lines << " # TODO: set up input and verify output"
485
+ lines << " result = described_class.call"
486
+ lines << " expect(result).to be_truthy"
487
+ lines << " end"
488
+ lines << " end"
489
+ lines << "end"
490
+ lines << "```"
491
+ else
492
+ path = "test/services/#{class_name.underscore}_test.rb"
493
+ lines = [ "# #{path}", "", "```ruby", "# frozen_string_literal: true", "", "require \"test_helper\"", "" ]
494
+ lines << "class #{class_name}Test < ActiveSupport::TestCase"
495
+ lines << " test \"performs the expected action\" do"
496
+ lines << " # TODO: set up input and verify output"
497
+ lines << " end"
498
+ lines << "end"
499
+ lines << "```"
500
+ end
501
+ text_response(lines.join("\n"))
502
+ end
503
+
504
+ # ── Helpers ──────────────────────────────────────────────────────
505
+
506
+ def find_factory_name(model_name, tests_data)
507
+ factory_names = tests_data[:factory_names] || {}
508
+ underscore = model_name.underscore
509
+ # Look for a factory matching the model name
510
+ factory_names.each_value do |names|
511
+ return underscore.to_sym if names.include?(underscore.to_sym) || names.include?(underscore)
512
+ end
513
+ # Check if factories directory exists at all
514
+ tests_data[:factories] ? underscore.to_sym : nil
515
+ end
516
+ end
517
+ end
518
+ end
519
+ end
@@ -151,12 +151,12 @@ module RailsAiContext
151
151
  in_ctrl = ctrl_ivars.include?(ivar)
152
152
  in_view = view_ivars.include?(ivar)
153
153
  if in_ctrl && in_view
154
- lines << "- \\u2713 @#{ivar} — set in controller, used in view"
154
+ lines << "- \u2713 @#{ivar} — set in controller, used in view"
155
155
  elsif in_view && !in_ctrl
156
- lines << "- \\u2717 @#{ivar} — used in view but NOT set in controller"
156
+ lines << "- \u2717 @#{ivar} — used in view but NOT set in controller"
157
157
  mismatches = true
158
158
  elsif in_ctrl && !in_view
159
- lines << "- \\u26A0 @#{ivar} — set in controller but not used in view"
159
+ lines << "- \u26A0 @#{ivar} — set in controller but not used in view"
160
160
  end
161
161
  end
162
162