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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/CLAUDE.md +4 -4
- data/CONTRIBUTING.md +1 -1
- data/README.md +7 -7
- data/SECURITY.md +1 -1
- data/docs/GUIDE.md +3 -3
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -2
- data/lib/rails_ai_context/doctor.rb +8 -1
- data/lib/rails_ai_context/introspectors/model_introspector.rb +1 -1
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -1
- data/lib/rails_ai_context/serializers/claude_serializer.rb +32 -12
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +2 -1
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +3 -2
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +2 -1
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +6 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +159 -64
- data/lib/rails_ai_context/server.rb +5 -1
- data/lib/rails_ai_context/tools/diagnose.rb +309 -0
- data/lib/rails_ai_context/tools/generate_test.rb +519 -0
- data/lib/rails_ai_context/tools/get_context.rb +3 -3
- data/lib/rails_ai_context/tools/onboard.rb +453 -0
- data/lib/rails_ai_context/tools/query.rb +13 -1
- data/lib/rails_ai_context/tools/review_changes.rb +290 -0
- data/lib/rails_ai_context/tools/search_code.rb +6 -3
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +8 -4
|
@@ -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 << "-
|
|
154
|
+
lines << "- \u2713 @#{ivar} — set in controller, used in view"
|
|
155
155
|
elsif in_view && !in_ctrl
|
|
156
|
-
lines << "-
|
|
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 << "-
|
|
159
|
+
lines << "- \u26A0 @#{ivar} — set in controller but not used in view"
|
|
160
160
|
end
|
|
161
161
|
end
|
|
162
162
|
|