rails-erd 2.0.2 → 2.2.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.
@@ -128,6 +128,7 @@ module RailsERD
128
128
  .reject { |model| tableless_rails_models.include?(model) }
129
129
  .select { |model| check_model_validity(model) }
130
130
  .reject { |model| check_habtm_model(model) }
131
+ .sort_by { |model| model.name.to_s }
131
132
  end
132
133
 
133
134
  # Returns Rails model classes defined in the app
@@ -187,7 +188,39 @@ module RailsERD
187
188
  def excluded_model?(model)
188
189
  return false unless options.exclude.present?
189
190
 
190
- [options.exclude].flatten.map(&:to_sym).include?(model.name.to_sym)
191
+ patterns = [options.exclude].flatten
192
+ patterns.any? { |pattern| matches_pattern?(pattern, model.name) }
193
+ end
194
+
195
+ # Matches a name against a pattern. Supports three pattern types:
196
+ #
197
+ # - Exact match: "Foo" matches only "Foo"
198
+ # - Glob pattern: "SolidQueue::*" matches "SolidQueue::Job", etc.
199
+ # - Regex pattern: "/^Active/" matches "ActiveRecord", "ActiveStorage::Blob"
200
+ #
201
+ def matches_pattern?(pattern, name)
202
+ pattern_str = pattern.to_s
203
+
204
+ # Regex pattern: /pattern/ or /pattern/flags
205
+ #
206
+ if pattern_str.start_with?("/") && pattern_str =~ %r{\A/(.+)/([imx]*)\z}
207
+ regex_body = Regexp.last_match(1)
208
+ flags_str = Regexp.last_match(2)
209
+ flags = 0
210
+ flags |= Regexp::IGNORECASE if flags_str.include?("i")
211
+ flags |= Regexp::MULTILINE if flags_str.include?("m")
212
+ flags |= Regexp::EXTENDED if flags_str.include?("x")
213
+ return Regexp.new(regex_body, flags).match?(name.to_s)
214
+ end
215
+
216
+ # Glob pattern: contains *, ?, or [
217
+ #
218
+ if pattern_str.include?("*") || pattern_str.include?("?") || pattern_str.include?("[")
219
+ return File.fnmatch?(pattern_str, name.to_s)
220
+ end
221
+
222
+ # Exact match (backward compatible)
223
+ pattern_str == name.to_s
191
224
  end
192
225
 
193
226
  def check_association_validity(association)
@@ -207,13 +240,14 @@ module RailsERD
207
240
  def excluded_association?(association)
208
241
  return false unless options.exclude.present?
209
242
 
210
- excluded_names = [options.exclude].flatten.map(&:to_sym)
243
+ patterns = [options.exclude].flatten
211
244
 
212
245
  # Suppress warning if either the source model or target model is excluded
213
- return true if excluded_names.include?(association.active_record.name.to_sym)
246
+ source_name = association.active_record.name
247
+ return true if patterns.any? { |pattern| matches_pattern?(pattern, source_name) }
214
248
 
215
249
  target_name = association.options[:polymorphic] ? association.class_name : association.klass.name
216
- target_name && excluded_names.include?(target_name.to_sym)
250
+ target_name && patterns.any? { |pattern| matches_pattern?(pattern, target_name) }
217
251
  rescue NameError
218
252
  # If we can't determine the target class, the source model was already checked above
219
253
  false
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsERD
4
- VERSION = "2.0.2"
4
+ VERSION = "2.2.0"
5
5
  BANNER = "RailsERD #{VERSION}"
6
6
  end
data/lib/rails_erd.rb CHANGED
@@ -51,12 +51,14 @@ module RailsERD
51
51
  :notation, :simple,
52
52
  :orientation, :horizontal,
53
53
  :polymorphism, false,
54
+ :recursive, true,
54
55
  :sort, true,
55
56
  :warn, true,
56
57
  :title, true,
57
58
  :exclude, nil,
58
59
  :exclude_attributes, nil,
59
60
  :only, nil,
61
+ :only_attributes, nil,
60
62
  :only_recursion_depth, nil,
61
63
  :prepend_primary, false,
62
64
  :cluster, false,
data/test/test_helper.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  require "rubygems"
2
2
  require "bundler/setup"
3
3
  require 'pry'
4
- require 'pry-nav'
4
+ require 'pry-byebug'
5
5
  require 'tmpdir'
6
6
 
7
7
  require "active_record"
@@ -133,6 +133,17 @@ class ConfigTest < ActiveSupport::TestCase
133
133
  assert_equal expected, normalize_value(:exclude_attributes, "BigTable,User.password_digest")
134
134
  end
135
135
 
136
+ test "normalize_value should canonicalize a hash when key is :only_attributes." do
137
+ value = { "Book" => ["title", "isbn"] }
138
+ expected = { "Book" => ["title", "isbn"] }
139
+ assert_equal expected, normalize_value(:only_attributes, value)
140
+ end
141
+
142
+ test "normalize_value should parse a string when key is :only_attributes." do
143
+ expected = { "Book" => ["title", "isbn"] }
144
+ assert_equal expected, normalize_value(:only_attributes, "Book.title,Book.isbn")
145
+ end
146
+
136
147
  test "normalize_value should return hash with symbol keys when key is :fonts and value is a hash." do
137
148
  fonts_value = { "normal" => "Arial", "bold" => "Arial Bold", "italic" => "Arial Italic" }
138
149
  expected = {:normal => "Arial", :bold => "Arial Bold", :italic => "Arial Italic"}
@@ -165,6 +165,76 @@ class DiagramTest < ActiveSupport::TestCase
165
165
  assert_equal [Author, Editor], retrieve_entities(:only => ['Author', 'Editor']).map(&:model)
166
166
  end
167
167
 
168
+ test "generate should exclude relationships whose endpoints were removed by :only" do
169
+ create_model "Author"
170
+ create_model "Book", :author => :references do
171
+ belongs_to :author
172
+ has_many :reviews
173
+ end
174
+ create_model "Review", :book => :references do
175
+ belongs_to :book
176
+ end
177
+ relationships = retrieve_relationships(:only => [:Author, :Book])
178
+ assert_equal [Set[Author, Book]], relationships.map { |r| Set[r.source.model, r.destination.model] }
179
+ end
180
+
181
+ test "generate should exclude relationships to an excluded entity" do
182
+ create_model "Author"
183
+ create_model "Book", :author => :references do
184
+ belongs_to :author
185
+ has_many :reviews
186
+ end
187
+ create_model "Review", :book => :references do
188
+ belongs_to :book
189
+ end
190
+ relationships = retrieve_relationships(:exclude => [:Review])
191
+ assert_equal [Set[Author, Book]], relationships.map { |r| Set[r.source.model, r.destination.model] }
192
+ end
193
+
194
+ # Pattern matching for exclude/only ==========================================
195
+ test "generate should filter entities matching glob pattern in exclude" do
196
+ create_module_model "SolidQueue::Job"
197
+ create_module_model "SolidQueue::Process"
198
+ create_model "User"
199
+ assert_equal [User], retrieve_entities(:exclude => ["SolidQueue::*"]).map(&:model)
200
+ end
201
+
202
+ test "generate should filter entities matching regex pattern in exclude" do
203
+ create_module_model "SolidQueue::Job"
204
+ create_model "User"
205
+ assert_equal [User], retrieve_entities(:exclude => ["/^Solid/"]).map(&:model)
206
+ end
207
+
208
+ test "generate should include only entities matching glob pattern in only" do
209
+ create_module_model "MyApp::User"
210
+ create_module_model "MyApp::Post"
211
+ create_model "SomeOther"
212
+ entities = retrieve_entities(:only => ["MyApp::*"]).map(&:model)
213
+ assert_includes entities, MyApp::User
214
+ assert_includes entities, MyApp::Post
215
+ refute_includes entities, SomeOther
216
+ end
217
+
218
+ test "generate should include only entities matching regex pattern in only" do
219
+ create_model "AdminUser"
220
+ create_model "AdminPost"
221
+ create_model "GuestUser"
222
+ entities = retrieve_entities(:only => ["/^Admin/"]).map(&:model)
223
+ assert_includes entities, AdminUser
224
+ assert_includes entities, AdminPost
225
+ refute_includes entities, GuestUser
226
+ end
227
+
228
+ test "generate should exclude relationships when endpoint matches glob pattern" do
229
+ create_module_model "SolidQueue::Job"
230
+ create_model "Task", :solid_queue_job => :references do
231
+ belongs_to :solid_queue_job, :class_name => "SolidQueue::Job"
232
+ end
233
+ create_model "User"
234
+ relationships = retrieve_relationships(:exclude => ["SolidQueue::*"])
235
+ assert_equal [], relationships
236
+ end
237
+
168
238
  test "generate should filter disconnected entities if disconnected is false" do
169
239
  create_model "Book", :author => :references do
170
240
  belongs_to :author
@@ -235,7 +305,7 @@ class DiagramTest < ActiveSupport::TestCase
235
305
  create_model "Baz", :foo => :references do
236
306
  belongs_to :foo
237
307
  end
238
- assert_equal [false, false, true], retrieve_relationships(:indirect => true).map(&:indirect?)
308
+ assert_equal({ false => 2, true => 1 }, retrieve_relationships(:indirect => true).map(&:indirect?).tally)
239
309
  end
240
310
 
241
311
  test "generate should filter indirect relationships if indirect is false" do
@@ -253,6 +323,20 @@ class DiagramTest < ActiveSupport::TestCase
253
323
  assert_equal [false, false], retrieve_relationships(:indirect => false).map(&:indirect?)
254
324
  end
255
325
 
326
+ test "generate should yield self referential relationships if recursive is true" do
327
+ create_model "Node", :parent => :references do
328
+ belongs_to :parent, :class_name => "Node"
329
+ end
330
+ assert_equal [true], retrieve_relationships(:recursive => true).map(&:recursive?)
331
+ end
332
+
333
+ test "generate should filter self referential relationships if recursive is false" do
334
+ create_model "Node", :parent => :references do
335
+ belongs_to :parent, :class_name => "Node"
336
+ end
337
+ assert_equal [], retrieve_relationships(:recursive => false)
338
+ end
339
+
256
340
  test "generate should yield relationships from specialized entities" do
257
341
  create_model "Foo", :bar => :references
258
342
  create_model "Bar", :type => :string
@@ -302,6 +386,21 @@ class DiagramTest < ActiveSupport::TestCase
302
386
  :polymorphism => true).map { |s| s.specialized.name }
303
387
  end
304
388
 
389
+ test "generate should not yield specializations whose entity is not part of the domain" do
390
+ # An abstract parent whose child model has no table (e.g. ActionMailbox::Record
391
+ # with a tableless ActionMailbox::InboundEmail): the child is excluded from the
392
+ # domain, so the specialization resolves to a nameless Null entity. It must not
393
+ # be yielded, otherwise generators emit an edge to a nameless entity (which is
394
+ # invalid Mermaid output).
395
+ Object.const_set "GhostRecord", Class.new(ActiveRecord::Base) { self.abstract_class = true }
396
+ Object.const_set "GhostThing", Class.new(GhostRecord)
397
+
398
+ specializations = retrieve_specializations(:inheritance => true, :polymorphism => true)
399
+ assert_equal [], specializations.select { |s|
400
+ s.generalized.name.to_s.empty? || s.specialized.name.to_s.empty?
401
+ }
402
+ end
403
+
305
404
  # Attribute filtering ======================================================
306
405
  test "generate should yield content attributes by default" do
307
406
  create_model "Book", :title => :string, :created_at => :datetime, :author => :references do
@@ -388,4 +487,95 @@ class DiagramTest < ActiveSupport::TestCase
388
487
  assert_equal %w{title}, attribute_lists[Book].map(&:name)
389
488
  assert_equal [], attribute_lists[Author].map(&:name)
390
489
  end
490
+
491
+ test "generate should show only the listed attributes for a model" do
492
+ create_model "Book", :title => :string, :subtitle => :string, :pages => :integer
493
+ attribute_lists = retrieve_attribute_lists(:only_attributes => { "Book" => ["title", "pages"] })
494
+ assert_equal %w{pages title}, attribute_lists[Book].map(&:name).sort
495
+ end
496
+
497
+ test "generate should restrict attributes for the listed model only" do
498
+ create_model "Book", :title => :string, :subtitle => :string
499
+ create_model "Author", :name => :string, :born_on => :date
500
+ attribute_lists = retrieve_attribute_lists(:only_attributes => { "Book" => ["title"] })
501
+ assert_equal %w{title}, attribute_lists[Book].map(&:name)
502
+ assert_equal %w{born_on name}, attribute_lists[Author].map(&:name).sort
503
+ end
504
+
505
+ test "generate should accept only_attributes as a string" do
506
+ create_model "Book", :title => :string, :subtitle => :string
507
+ create_model "Author", :name => :string
508
+ attribute_lists = retrieve_attribute_lists(:only_attributes => "Book.title")
509
+ assert_equal %w{title}, attribute_lists[Book].map(&:name)
510
+ assert_equal %w{name}, attribute_lists[Author].map(&:name)
511
+ end
512
+
513
+ test "generate should keep all attributes for a model listed in only_attributes without any attribute" do
514
+ create_model "Book", :title => :string, :subtitle => :string
515
+ attribute_lists = retrieve_attribute_lists(:only_attributes => "Book")
516
+ assert_equal %w{subtitle title}, attribute_lists[Book].map(&:name).sort
517
+ end
518
+
519
+ test "generate should not show attributes selected by only_attributes but rejected by attributes" do
520
+ create_model "Book", :title => :string, :created_at => :datetime
521
+ attribute_lists = retrieve_attribute_lists(:only_attributes => { "Book" => ["title", "created_at"] },
522
+ :attributes => [:content])
523
+ assert_equal %w{title}, attribute_lists[Book].map(&:name)
524
+ end
525
+
526
+ test "generate should apply only_attributes before exclude_attributes" do
527
+ create_model "Book", :title => :string, :subtitle => :string, :pages => :integer
528
+ attribute_lists = retrieve_attribute_lists(:only_attributes => { "Book" => ["title", "subtitle"] },
529
+ :exclude_attributes => { "Book" => ["subtitle"] })
530
+ assert_equal %w{title}, attribute_lists[Book].map(&:name)
531
+ end
532
+
533
+ test "generate should hide all attributes when a model is in both only_attributes and fully excluded" do
534
+ create_model "Book", :title => :string, :subtitle => :string
535
+ attribute_lists = retrieve_attribute_lists(:only_attributes => { "Book" => ["title"] },
536
+ :exclude_attributes => { "Book" => true })
537
+ assert_equal [], attribute_lists[Book].map(&:name)
538
+ end
539
+
540
+ test "normalize_only_attributes should split namespaced models on the first dot only" do
541
+ assert_equal({ "Admin::User" => ["email"] },
542
+ Diagram.normalize_only_attributes("Admin::User.email"))
543
+ end
544
+
545
+ test "normalize_only_attributes should return an empty hash for nil" do
546
+ assert_equal({}, Diagram.normalize_only_attributes(nil))
547
+ end
548
+
549
+ test "normalize_only_attributes should return an empty hash for false" do
550
+ assert_equal({}, Diagram.normalize_only_attributes(false))
551
+ end
552
+
553
+ test "normalize_only_attributes should raise for true" do
554
+ assert_raises ArgumentError do
555
+ Diagram.normalize_only_attributes(true)
556
+ end
557
+ end
558
+
559
+ test "normalize_exclude_attributes should split namespaced models on the first dot only" do
560
+ assert_equal({ "Admin::User" => ["password_digest"] },
561
+ Diagram.normalize_exclude_attributes("Admin::User.password_digest"))
562
+ end
563
+
564
+ test "normalize_exclude_attributes should treat a bare namespaced model as hide all" do
565
+ assert_equal({ "Admin::User" => true },
566
+ Diagram.normalize_exclude_attributes("Admin::User"))
567
+ end
568
+
569
+ test "normalize_exclude_attributes should return an empty hash for nil" do
570
+ assert_equal({}, Diagram.normalize_exclude_attributes(nil))
571
+ end
572
+
573
+ test "normalize_exclude_attributes should return an empty hash for false" do
574
+ assert_equal({}, Diagram.normalize_exclude_attributes(false))
575
+ end
576
+
577
+ test "normalize_exclude_attributes should ignore blank entries in a string" do
578
+ assert_equal({ "Book" => true },
579
+ Diagram.normalize_exclude_attributes("Book, ,"))
580
+ end
391
581
  end
@@ -151,6 +151,23 @@ class DomainTest < ActiveSupport::TestCase
151
151
  assert_equal ["Many", "More"], [relationship.source.name, relationship.destination.name]
152
152
  end
153
153
 
154
+ test "relationships should be deterministic regardless of model order" do
155
+ create_model "Author"
156
+ create_model "Book", :author => :references do
157
+ belongs_to :author
158
+ has_many :reviews
159
+ end
160
+ create_model "Review", :book => :references do
161
+ belongs_to :book
162
+ end
163
+ pairs = lambda do |models|
164
+ Domain.new(models).relationships.collect { |r| [r.source.name, r.destination.name] }
165
+ end
166
+ models = [Author, Book, Review]
167
+ # Order and source/destination direction must not depend on the input model order.
168
+ assert_equal pairs.call(models), pairs.call(models.reverse)
169
+ end
170
+
154
171
  # Specialization processing ================================================
155
172
  test "specializations should return empty array for empty domain" do
156
173
  assert_equal [], Domain.generate.specializations
@@ -301,4 +318,91 @@ class DomainTest < ActiveSupport::TestCase
301
318
  end
302
319
  assert_equal "", output
303
320
  end
321
+
322
+ # Pattern matching for exclude/only ==========================================
323
+ test "matches_pattern? should match exact string" do
324
+ domain = Domain.new([])
325
+ assert domain.send(:matches_pattern?, "Foo", "Foo")
326
+ refute domain.send(:matches_pattern?, "Foo", "Bar")
327
+ end
328
+
329
+ test "matches_pattern? should match glob pattern with asterisk" do
330
+ domain = Domain.new([])
331
+ assert domain.send(:matches_pattern?, "SolidQueue::*", "SolidQueue::Job")
332
+ assert domain.send(:matches_pattern?, "SolidQueue::*", "SolidQueue::Process")
333
+ refute domain.send(:matches_pattern?, "SolidQueue::*", "SolidCache::Entry")
334
+ end
335
+
336
+ test "matches_pattern? should match glob pattern without namespace separator" do
337
+ domain = Domain.new([])
338
+ assert domain.send(:matches_pattern?, "ActiveStorage*", "ActiveStorageBlob")
339
+ assert domain.send(:matches_pattern?, "ActiveStorage*", "ActiveStorage::Blob")
340
+ refute domain.send(:matches_pattern?, "ActiveStorage*", "ActionMailbox::InboundEmail")
341
+ end
342
+
343
+ test "matches_pattern? should match regex pattern" do
344
+ domain = Domain.new([])
345
+ assert domain.send(:matches_pattern?, "/^Active/", "ActiveRecord")
346
+ assert domain.send(:matches_pattern?, "/^Active/", "ActiveStorage::Blob")
347
+ refute domain.send(:matches_pattern?, "/^Active/", "SolidQueue::Job")
348
+ end
349
+
350
+ test "matches_pattern? should match regex pattern with flags" do
351
+ domain = Domain.new([])
352
+ assert domain.send(:matches_pattern?, "/queue/i", "SolidQueue::Job")
353
+ refute domain.send(:matches_pattern?, "/queue/", "SolidQueue::Job")
354
+ end
355
+
356
+ test "excluded_model? should match glob patterns" do
357
+ create_module_model "SolidQueue::Job"
358
+ create_module_model "SolidQueue::Process"
359
+ create_model "User"
360
+
361
+ domain = Domain.generate(:exclude => ["SolidQueue::*"])
362
+
363
+ # excluded_model? returns true for matching patterns
364
+ assert domain.send(:excluded_model?, SolidQueue::Job)
365
+ assert domain.send(:excluded_model?, SolidQueue::Process)
366
+ refute domain.send(:excluded_model?, User)
367
+ end
368
+
369
+ test "excluded_model? should match regex patterns" do
370
+ create_module_model "SolidQueue::Job"
371
+ create_model "User"
372
+
373
+ domain = Domain.generate(:exclude => ["/^Solid/"])
374
+ assert domain.send(:excluded_model?, SolidQueue::Job)
375
+ refute domain.send(:excluded_model?, User)
376
+ end
377
+
378
+ test "excluded_model? should still match exact names for backward compatibility" do
379
+ create_model "Foo"
380
+ create_model "Bar"
381
+
382
+ domain = Domain.generate(:exclude => ["Foo"])
383
+ assert domain.send(:excluded_model?, Foo)
384
+ refute domain.send(:excluded_model?, Bar)
385
+ end
386
+
387
+ test "excluded_association? should match glob patterns for source model" do
388
+ create_module_model "SolidQueue::Job" do
389
+ has_many :executions
390
+ end
391
+ create_model "User"
392
+
393
+ domain = Domain.generate(:exclude => ["SolidQueue::*"], :warn => false)
394
+ association = SolidQueue::Job.reflect_on_association(:executions)
395
+ assert domain.send(:excluded_association?, association)
396
+ end
397
+
398
+ test "excluded_association? should match glob patterns for target model" do
399
+ create_module_model "SolidQueue::Execution"
400
+ create_model "Task" do
401
+ has_many :executions, :class_name => "SolidQueue::Execution"
402
+ end
403
+
404
+ domain = Domain.generate(:exclude => ["SolidQueue::*"], :warn => false)
405
+ association = Task.reflect_on_association(:executions)
406
+ assert domain.send(:excluded_association?, association)
407
+ end
304
408
  end
@@ -188,13 +188,30 @@ class MermaidTest < ActiveSupport::TestCase
188
188
  "\tclass `Cannon`",
189
189
  "\tclass `Galleon`",
190
190
  "\tclass `Stronghold`",
191
- "\t`Defensible` --> `Cannon`",
192
191
  "\t`Galleon` --> `Cannon`",
193
192
  "\t`Stronghold` --> `Cannon`"
194
193
  ]
195
194
  assert_equal expected, diagram.graph.uniq
196
195
  end
197
196
 
197
+ test "generate should not draw polymorphic entity that is omitted by only" do
198
+ create_model "Cannon", :defensible => :references do
199
+ belongs_to :defensible, :polymorphic => true
200
+ end
201
+ create_model "Galleon" do
202
+ has_many :cannons, :as => :defensible
203
+ end
204
+
205
+ expected = [
206
+ "classDiagram",
207
+ "\tdirection TB",
208
+ "\tclass `Cannon`",
209
+ "\tclass `Galleon`",
210
+ "\t`Galleon` --> `Cannon`"
211
+ ]
212
+ assert_equal expected, diagram(:only => %w{Cannon Galleon}).graph.uniq
213
+ end
214
+
198
215
  test "generate should support one to many relationships" do
199
216
  create_one_to_many_assoc_domain
200
217
 
@@ -230,9 +247,9 @@ class MermaidTest < ActiveSupport::TestCase
230
247
  "\tclass `Bar`",
231
248
  "\tclass `Baz`",
232
249
  "\tclass `Foo`",
233
- "\t`Foo` --> `Baz`",
234
250
  "\t`Foo` --> `Bar`",
235
- "\t`Bar` ..> `Baz`"
251
+ "\t`Bar` ..> `Baz`",
252
+ "\t`Foo` --> `Baz`"
236
253
  ]
237
254
 
238
255
  assert_equal expected, diagram.graph.uniq
@@ -437,4 +454,126 @@ class MermaidTest < ActiveSupport::TestCase
437
454
  # Verify the specialization relationship line also quotes it
438
455
  assert result.match?(/Vehicle.*--.*"Transport::Car"/), "Specialization relationship should quote namespaced entity"
439
456
  end
457
+
458
+ # Namespace clustering tests (Issue #479) =====================================
459
+
460
+ test "classDiagram with cluster should group entities by namespace" do
461
+ create_model "Post"
462
+ create_module_model "Admin::Author", :post => :references do
463
+ belongs_to :post
464
+ end
465
+ create_module_model "Admin::Role"
466
+
467
+ result = diagram(:cluster => true).graph.join("\n")
468
+
469
+ assert result.include?("classDiagram")
470
+ # Should have namespace block for Admin
471
+ assert result.include?("namespace Admin {"), "Should have namespace block for Admin"
472
+ # Author and Role should be inside the Admin namespace
473
+ assert result.match?(/namespace Admin \{[^}]*class `Author`/m), "Author should be inside Admin namespace"
474
+ assert result.match?(/namespace Admin \{[^}]*class `Role`/m), "Role should be inside Admin namespace"
475
+ end
476
+
477
+ test "classDiagram with cluster should place entities without namespace outside blocks" do
478
+ create_model "Post"
479
+ create_module_model "Admin::Author", :post => :references do
480
+ belongs_to :post
481
+ end
482
+
483
+ result = diagram(:cluster => true).graph.join("\n")
484
+
485
+ # Post should appear before any namespace block
486
+ post_index = result.index("class `Post`")
487
+ namespace_index = result.index("namespace Admin {")
488
+ assert post_index < namespace_index, "Entities without namespace should appear before namespace blocks"
489
+ end
490
+
491
+ test "classDiagram with cluster should handle nested namespaces" do
492
+ create_model "Post"
493
+ create_module_model "Admin::Users::Role", :post => :references do
494
+ belongs_to :post
495
+ end
496
+
497
+ result = diagram(:cluster => true).graph.join("\n")
498
+
499
+ # Nested namespace should use dot notation (Admin.Users)
500
+ assert result.include?("namespace Admin.Users {"), "Should convert :: to . in namespace names"
501
+ assert result.match?(/namespace Admin\.Users \{[^}]*class `Role`/m), "Role should be inside Admin.Users namespace"
502
+ end
503
+
504
+ test "classDiagram with cluster should render relationships outside namespace blocks" do
505
+ create_model "Post"
506
+ create_module_model "Admin::Author", :post => :references do
507
+ belongs_to :post
508
+ end
509
+
510
+ result = diagram(:cluster => true).graph.join("\n")
511
+
512
+ # Relationship should appear after the namespace block closes
513
+ namespace_close_index = result.index("}")
514
+ relationship_index = result.index("`Post` --> `Admin::Author`")
515
+ assert relationship_index > namespace_close_index, "Relationships should appear after namespace blocks"
516
+ end
517
+
518
+ test "erDiagram with cluster should emit warning and render flat" do
519
+ create_model "Post"
520
+ create_module_model "Admin::Author", :post => :references do
521
+ belongs_to :post
522
+ end
523
+
524
+ test_diagram = nil
525
+
526
+ warning_output = collect_stdout do
527
+ domain = Domain.generate
528
+ test_diagram = Diagram::Mermaid.new(domain, :cluster => true, :mermaid_style => :erdiagram, :warn => true)
529
+ test_diagram.generate
530
+ end
531
+
532
+ result = test_diagram.graph.join("\n")
533
+
534
+ # Should emit warning about clustering not supported
535
+ assert warning_output.include?("Clustering is not supported"), "Should warn about clustering not supported in erDiagram"
536
+ # Should still render the diagram (flat, no namespace blocks)
537
+ assert result.include?("erDiagram")
538
+ refute result.include?("namespace"), "erDiagram should not have namespace blocks"
539
+ end
540
+
541
+ test "classDiagram with cluster false should not group entities" do
542
+ create_model "Post"
543
+ create_module_model "Admin::Author", :post => :references do
544
+ belongs_to :post
545
+ end
546
+
547
+ result = diagram(:cluster => false).graph.join("\n")
548
+
549
+ assert result.include?("classDiagram")
550
+ # Should NOT have namespace blocks
551
+ refute result.include?("namespace"), "cluster: false should not create namespace blocks"
552
+ # Should use full entity names
553
+ assert result.include?("class `Admin::Author`"), "Should use full entity name without clustering"
554
+ end
555
+
556
+ test "classDiagram with cluster and inheritance should emit entities before specializations" do
557
+ create_model "Vehicle", :type => :string
558
+ create_module_model "Transport::Car", Vehicle
559
+
560
+ result = diagram(:cluster => true, :inheritance => true).graph.join("\n")
561
+
562
+ # Entity class definitions should appear BEFORE specialization lines
563
+ vehicle_class_index = result.index("class `Vehicle`")
564
+ polymorphic_index = result.index("<<polymorphic>>")
565
+ inheritance_index = result.index("<|--")
566
+
567
+ assert vehicle_class_index, "Should have Vehicle class definition.\nGot:\n#{result}"
568
+ assert polymorphic_index, "Should have polymorphic marker.\nGot:\n#{result}"
569
+ assert inheritance_index, "Should have inheritance relationship.\nGot:\n#{result}"
570
+
571
+ # The key assertion: class definitions must come BEFORE specializations
572
+ assert vehicle_class_index < polymorphic_index,
573
+ "Class definitions should appear before polymorphic markers.\n" \
574
+ "Got:\n#{result}"
575
+ assert vehicle_class_index < inheritance_index,
576
+ "Class definitions should appear before inheritance relationships.\n" \
577
+ "Got:\n#{result}"
578
+ end
440
579
  end