admin_suite 0.3.1 → 0.5.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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +210 -1
  3. data/app/assets/vendor/chart.umd.min.js +14 -0
  4. data/app/assets/vendor/easymde.min.css +7 -0
  5. data/app/assets/vendor/easymde.min.js +7 -0
  6. data/app/controllers/admin_suite/application_controller.rb +26 -47
  7. data/app/controllers/admin_suite/portals_controller.rb +2 -2
  8. data/app/controllers/admin_suite/resources_controller.rb +138 -6
  9. data/app/helpers/admin_suite/base_helper.rb +232 -391
  10. data/app/javascript/admin_suite_application.js +3 -0
  11. data/app/javascript/controllers/admin_suite/chart_controller.js +173 -0
  12. data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +21 -2
  13. data/app/views/admin_suite/panels/_chart.html.erb +158 -18
  14. data/app/views/admin_suite/panels/_stat.html.erb +10 -0
  15. data/app/views/admin_suite/resources/index.html.erb +31 -82
  16. data/app/views/admin_suite/shared/_pagination.html.erb +74 -0
  17. data/app/views/admin_suite/shared/_sidebar.html.erb +15 -9
  18. data/app/views/layouts/admin_suite/application.html.erb +11 -3
  19. data/config/routes.rb +3 -0
  20. data/lib/admin/base/action_executor.rb +19 -51
  21. data/lib/admin/base/filter_builder.rb +48 -5
  22. data/lib/admin/base/resource.rb +92 -17
  23. data/lib/admin_suite/configuration.rb +32 -3
  24. data/lib/admin_suite/definition_loader.rb +194 -0
  25. data/lib/admin_suite/deprecation.rb +48 -0
  26. data/lib/admin_suite/engine.rb +55 -76
  27. data/lib/admin_suite/host_autoload_policy.rb +132 -0
  28. data/lib/admin_suite/legacy_custom_renderer_procs.rb +29 -0
  29. data/lib/admin_suite/portal_definition.rb +11 -0
  30. data/lib/admin_suite/renderer.rb +133 -0
  31. data/lib/admin_suite/renderer_registry.rb +81 -0
  32. data/lib/admin_suite/renderers/code_renderer.rb +15 -0
  33. data/lib/admin_suite/renderers/json_renderer.rb +15 -0
  34. data/lib/admin_suite/renderers/key_value_renderer.rb +39 -0
  35. data/lib/admin_suite/renderers/legacy_gleania.rb +232 -0
  36. data/lib/admin_suite/renderers/table_from_renderer.rb +22 -0
  37. data/lib/admin_suite/section_definition.rb +42 -0
  38. data/lib/admin_suite/ui/dashboard_definition.rb +6 -0
  39. data/lib/admin_suite/ui/field_renderer_registry.rb +31 -4
  40. data/lib/admin_suite/ui/form_field_renderer.rb +1 -7
  41. data/lib/admin_suite/ui/show_formatter_registry.rb +9 -0
  42. data/lib/admin_suite/ui/show_value_formatter.rb +7 -3
  43. data/lib/admin_suite/version.rb +1 -1
  44. data/lib/admin_suite.rb +48 -0
  45. data/lib/generators/admin_suite/install/templates/admin_suite.rb +0 -4
  46. data/test/controllers/resources_controller_test.rb +76 -1
  47. data/test/integration/association_linking_test.rb +292 -0
  48. data/test/integration/chart_panel_test.rb +491 -0
  49. data/test/integration/dashboard_test.rb +9 -2
  50. data/test/integration/index_table_test.rb +289 -0
  51. data/test/integration/layout_assets_test.rb +112 -0
  52. data/test/integration/navigation_sections_test.rb +62 -0
  53. data/test/integration/pagination_and_stats_test.rb +152 -0
  54. data/test/integration/searchable_select_search_test.rb +368 -0
  55. data/test/integration/show_hide_blank_test.rb +195 -0
  56. data/test/integration/toggle_test.rb +106 -0
  57. data/test/lib/action_executor_redirect_test.rb +41 -0
  58. data/test/lib/builtin_renderers_test.rb +202 -0
  59. data/test/lib/definition_loader_test.rb +276 -0
  60. data/test/lib/engine_defaults_test.rb +39 -0
  61. data/test/lib/form_field_renderer_test.rb +136 -0
  62. data/test/lib/format_table_cell_test.rb +49 -0
  63. data/test/lib/index_includes_test.rb +223 -0
  64. data/test/lib/legacy_renderer_deprecation_test.rb +42 -0
  65. data/test/lib/renderer_test.rb +237 -0
  66. data/test/lib/resource_exportable_deprecation_test.rb +47 -0
  67. data/test/lib/show_value_formatter_test.rb +88 -0
  68. data/test/lib/zeitwerk_integration_test.rb +28 -64
  69. data/test/test_helper.rb +121 -5
  70. metadata +62 -5
@@ -4,12 +4,9 @@ require "admin_suite/ui/field_renderer_registry"
4
4
 
5
5
  module AdminSuite
6
6
  module UI
7
- # Overrides `render_form_field` to use a registry of field renderers,
8
- # while leaving the legacy implementation available via `super`.
7
+ # Implements `render_form_field` using a registry of field renderers.
9
8
  module FormFieldRenderer
10
9
  def render_form_field(f, field, resource)
11
- return super unless defined?(AdminSuite::UI::FieldRendererRegistry)
12
-
13
10
  return if field.if_condition.present? && !field.if_condition.call(resource)
14
11
  return if field.unless_condition.present? && field.unless_condition.call(resource)
15
12
 
@@ -33,9 +30,6 @@ module AdminSuite
33
30
  field_class: field_class
34
31
  )
35
32
 
36
- # If the registry doesn't know how to render, fall back to legacy behavior.
37
- return super if field_html.nil?
38
-
39
33
  concat(field_html)
40
34
 
41
35
  concat(content_tag(:p, field.help, class: "mt-1 text-sm text-slate-500")) if field.help.present?
@@ -102,6 +102,15 @@ AdminSuite::UI::ShowFormatterRegistry.register_class(Float) do |value, view, _re
102
102
  view.content_tag(:span, view.number_with_delimiter(value), class: "font-mono")
103
103
  end
104
104
 
105
+ AdminSuite::UI::ShowFormatterRegistry.register_class(BigDecimal) do |value, view, _record, _field|
106
+ # `.to_f` avoids number_with_delimiter rendering exponential notation
107
+ # (e.g. "0.98765e4") for BigDecimal input, at the cost of Float precision
108
+ # for values beyond Float's exact range (e.g. sub-cent monetary sums).
109
+ # That's an accepted display-only tradeoff here — don't "fix" this by
110
+ # dropping the conversion, or exponential notation comes back.
111
+ view.content_tag(:span, view.number_with_delimiter(value.to_f), class: "font-mono")
112
+ end
113
+
105
114
  AdminSuite::UI::ShowFormatterRegistry.register_default do |value, view, _record, field_name|
106
115
  value_str = value.to_s
107
116
 
@@ -4,8 +4,7 @@ require "admin_suite/ui/show_formatter_registry"
4
4
 
5
5
  module AdminSuite
6
6
  module UI
7
- # Overrides `format_show_value` to use a registry of show value formatters,
8
- # while leaving the legacy implementation available via `super`.
7
+ # Implements `format_show_value` using a registry of show value formatters.
9
8
  module ShowValueFormatter
10
9
  def format_show_value(record, field_name)
11
10
  value = record.public_send(field_name) rescue nil
@@ -70,7 +69,12 @@ module AdminSuite
70
69
 
71
70
  return formatted unless formatted.nil?
72
71
 
73
- super
72
+ # Unreachable in practice: the registry's unconditional default
73
+ # handler always returns a value for any input, so `formatted` is
74
+ # never nil here. This is a last-resort guard, not a real code path
75
+ # — kept only in case a future handler is registered that returns
76
+ # nil, so rendering degrades to a plain span instead of raising.
77
+ content_tag(:span, value.to_s, class: "text-slate-900")
74
78
  end
75
79
 
76
80
  private
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AdminSuite
4
4
  module Version
5
- VERSION = "0.3.1"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
 
8
8
  # Backward-compatible constant.
data/lib/admin_suite.rb CHANGED
@@ -8,17 +8,65 @@ end
8
8
 
9
9
  require "pagy"
10
10
 
11
+ # Hard dependency, declared in the gemspec but not a direct Gemfile entry in
12
+ # any host -- Bundler's `Bundler.require` only auto-requires gems declared
13
+ # directly in the Gemfile (or via `gemspec`'s own path entry), not a gem's
14
+ # *transitive* runtime dependencies. Same reasoning as `pagy` above and
15
+ # `redcarpet`/`rouge` in markdown_renderer.rb: required explicitly here so
16
+ # `turbo_frame_tag`/`turbo_stream`/the `:turbo_stream` MIME type are real
17
+ # regardless of what else the host's own Gemfile happens to require.
18
+ require "turbo-rails"
19
+
11
20
  require "admin_suite/version"
21
+ require "admin_suite/deprecation"
12
22
  require "admin_suite/configuration"
13
23
  require "admin_suite/markdown_renderer"
14
24
  require "admin_suite/theme_palette"
15
25
  require "admin_suite/portal_registry"
26
+ require "admin_suite/section_definition"
16
27
  require "admin_suite/portal_definition"
17
28
  require "admin_suite/auth"
18
29
  require "admin_suite/ui/form_field_renderer"
19
30
  require "admin_suite/ui/show_value_formatter"
31
+ require "admin_suite/renderer"
32
+ require "admin_suite/renderer_registry"
33
+ require "admin_suite/renderers/json_renderer"
34
+ require "admin_suite/renderers/key_value_renderer"
35
+ require "admin_suite/renderers/table_from_renderer"
36
+ require "admin_suite/renderers/code_renderer"
37
+ require "admin_suite/renderers/legacy_gleania"
38
+ require "admin_suite/legacy_custom_renderer_procs"
39
+ require "admin_suite/definition_loader"
40
+ require "admin_suite/host_autoload_policy"
20
41
  require "admin_suite/engine"
21
42
 
43
+ # Registered as *defaults* (register_default), not explicit registrations:
44
+ # `render_custom_section` checks a host's own `RendererRegistry.register`
45
+ # call and a host's `Admin::Renderers::<Key>Renderer` class *before* falling
46
+ # back to these, so a host following the deprecation advice below (or simply
47
+ # overriding a built-in like `:json`) actually takes effect instead of being
48
+ # silently shadowed by the gem's own boot-time registrations.
49
+ AdminSuite::RendererRegistry.register_default(:json, AdminSuite::Renderers::JsonRenderer)
50
+ AdminSuite::RendererRegistry.register_default(:key_value, AdminSuite::Renderers::KeyValueRenderer)
51
+ AdminSuite::RendererRegistry.register_default(:table_from, AdminSuite::Renderers::TableFromRenderer)
52
+ AdminSuite::RendererRegistry.register_default(:code, AdminSuite::Renderers::CodeRenderer)
53
+
54
+ # Aliases: `:json_preview`/`:code_preview` were the previous generic
55
+ # `render_custom_section` case branches (BaseHelper#render_json_preview /
56
+ # #render_code_preview, now deleted). Host resources declared against those
57
+ # keys keep working unchanged against the new built-in renderers.
58
+ AdminSuite::RendererRegistry.register_default(:json_preview, AdminSuite::Renderers::JsonRenderer)
59
+ AdminSuite::RendererRegistry.register_default(:code_preview, AdminSuite::Renderers::CodeRenderer)
60
+
61
+ # Deprecated (removal targeted at 0.6.0, moved from the originally-planned
62
+ # 0.5.0 -- both host migrations, gleania and trust_growth, are in flight):
63
+ # the four Gleania-specific LLM chat-transcript renderers. See
64
+ # `AdminSuite::Renderers::LegacyGleania` for details.
65
+ AdminSuite::RendererRegistry.register_default(:prompt_template_preview, AdminSuite::Renderers::LegacyGleania::PromptTemplateRenderer)
66
+ AdminSuite::RendererRegistry.register_default(:messages_preview, AdminSuite::Renderers::LegacyGleania::MessagesPreviewRenderer)
67
+ AdminSuite::RendererRegistry.register_default(:tool_args_preview, AdminSuite::Renderers::LegacyGleania::ToolArgsRenderer)
68
+ AdminSuite::RendererRegistry.register_default(:turn_messages_preview, AdminSuite::Renderers::LegacyGleania::TurnMessagesRenderer)
69
+
22
70
  module AdminSuite
23
71
  class << self
24
72
  # @return [AdminSuite::Configuration]
@@ -74,10 +74,6 @@ AdminSuite.configure do |config|
74
74
  # In apps that use Tailwind, this is typically `:app`.
75
75
  config.host_stylesheet = :app
76
76
 
77
- # Tailwind CDN fallback (helps when host doesn't compile Tailwind).
78
- # Disable if you provide your own Tailwind build.
79
- config.tailwind_cdn = true
80
-
81
77
  # Optional docs link shown in the sidebar.
82
78
  # config.docs_url = "https://..."
83
79
  config.docs_url = nil
@@ -5,7 +5,7 @@ require "test_helper"
5
5
  module AdminSuite
6
6
  class ResourcesControllerTest < ActiveSupport::TestCase
7
7
  class TestController < ResourcesController
8
- attr_writer :test_resource_config
8
+ attr_writer :test_resource_config, :test_params
9
9
  attr_reader :filter_calls, :paginated_scope
10
10
 
11
11
  def initialize
@@ -13,6 +13,10 @@ module AdminSuite
13
13
  @filter_calls = 0
14
14
  end
15
15
 
16
+ def params
17
+ @test_params ||= {}
18
+ end
19
+
16
20
  private
17
21
 
18
22
  def resource_config
@@ -76,5 +80,76 @@ module AdminSuite
76
80
  assert_equal 37, controller.instance_variable_get(:@stats).second[:value]
77
81
  assert_equal :paginated, controller.instance_variable_get(:@collection)
78
82
  end
83
+
84
+ # Finding 3(a) of the whole-branch review: `set_resource`'s bare
85
+ # `rescue ActiveRecord::RecordNotFound` is the masking variant. In a host
86
+ # without ActiveRecord loaded, resolving that constant while dispatching
87
+ # an exception raises `NameError`, destroying whatever the real error
88
+ # was (here, a PORO model that doesn't implement `column_names`).
89
+ class PoroWithoutColumns
90
+ def self.column_names
91
+ raise NoMethodError, "undefined method 'column_names' for #{name}"
92
+ end
93
+ end
94
+
95
+ class PoroResourceConfig < Admin::Base::Resource
96
+ model PoroWithoutColumns
97
+ end
98
+
99
+ # A model shape that implements just enough of the ActiveRecord surface
100
+ # `set_resource`/`find_friendly_resource!` touch to exercise the genuine
101
+ # `ActiveRecord::RecordNotFound` recovery path end-to-end.
102
+ class SlugLookupModel
103
+ Column = Struct.new(:type)
104
+ Record = Struct.new(:id, :slug)
105
+
106
+ def self.column_names
107
+ %w[id slug]
108
+ end
109
+
110
+ def self.primary_key
111
+ "id"
112
+ end
113
+
114
+ def self.columns_hash
115
+ { "id" => Column.new(:string) }
116
+ end
117
+
118
+ def self.find(_id)
119
+ raise ActiveRecord::RecordNotFound, "not found by primary key"
120
+ end
121
+
122
+ def self.find_by(slug:)
123
+ slug == "the-slug" ? Record.new(42, "the-slug") : nil
124
+ end
125
+ end
126
+
127
+ class SlugResourceConfig < Admin::Base::Resource
128
+ model SlugLookupModel
129
+ end
130
+
131
+ test "set_resource still recovers via find_friendly_resource! on a genuine ActiveRecord::RecordNotFound" do
132
+ controller = TestController.new
133
+ controller.test_resource_config = SlugResourceConfig
134
+ controller.test_params = { id: "the-slug" }
135
+
136
+ controller.send(:set_resource)
137
+
138
+ assert_equal 42, controller.send(:resource).id
139
+ end
140
+
141
+ test "set_resource propagates the original error instead of masking it with NameError" do
142
+ controller = TestController.new
143
+ controller.test_resource_config = PoroResourceConfig
144
+ controller.test_params = { id: "1" }
145
+
146
+ original = ActiveRecord::RecordNotFound
147
+ ActiveRecord.send(:remove_const, :RecordNotFound)
148
+
149
+ error = assert_raises(NoMethodError) { controller.send(:set_resource) }
150
+ assert_match(/column_names/, error.message)
151
+ ensure
152
+ ActiveRecord.const_set(:RecordNotFound, original) unless defined?(ActiveRecord::RecordNotFound)
153
+ end
79
154
  end
80
155
  end
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # `LinkingFixtures::Company` (an AR-backed fixture: it genuinely subclasses
6
+ # the `ActiveRecord::Base` stub defined in test_helper.rb) and its
7
+ # registered `Admin::Resources::LinkingCompanyResource` both live in
8
+ # test_helper.rb, not here -- established there because Tasks 4 and 7 reuse
9
+ # them, and test_helper.rb is required by every test file regardless of
10
+ # which single file `rake test TEST=...` runs. Everything below is specific
11
+ # to this task's link-rendering assertions.
12
+ module LinkingFixtures
13
+ # Same shape as Company, but deliberately never registered as an
14
+ # Admin::Resources::* class -- exercises the "no resource registered"
15
+ # degrade path in `auto_admin_suite_path_for`.
16
+ class UnregisteredVendor < ActiveRecord::Base
17
+ extend ActiveModel::Naming
18
+
19
+ attr_reader :id, :name
20
+
21
+ def initialize(id: 3, name: "Ghost Vendor")
22
+ @id = id
23
+ @name = name
24
+ end
25
+
26
+ def self.column_names = %w[id name]
27
+ def self.primary_key = "id"
28
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
29
+ def self.find(_id) = new
30
+ def to_param = id.to_s
31
+ def attributes = { "id" => id, "name" => name }
32
+ end
33
+
34
+ # `item_display_title` calls `item.name` unguarded when `respond_to?(:name)`
35
+ # is true. This record's `#name` raises, simulating a host model whose
36
+ # display method blows up on bad data -- the point is that a single bad
37
+ # row must degrade, not 500 the entire page.
38
+ class RaisingCo < ActiveRecord::Base
39
+ extend ActiveModel::Naming
40
+
41
+ attr_reader :id
42
+
43
+ def initialize(id: 9)
44
+ @id = id
45
+ end
46
+
47
+ def name
48
+ raise "boom: no display name for you"
49
+ end
50
+
51
+ def self.column_names = %w[id name]
52
+ def self.primary_key = "id"
53
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
54
+ def self.find(_id) = new
55
+ def to_param = id.to_s
56
+ def attributes = { "id" => id }
57
+ end
58
+
59
+ # Simulates an unsaved/unpersisted AR record: real `ActiveRecord::Base#to_param`
60
+ # returns nil when `persisted?` is false. `auto_admin_suite_path_for`'s
61
+ # `resource_path(..., id: nil)` call must degrade to no-link, not 500.
62
+ class UnpersistedVendor < ActiveRecord::Base
63
+ extend ActiveModel::Naming
64
+
65
+ attr_reader :id, :name
66
+
67
+ def initialize
68
+ @id = nil
69
+ @name = "Draft Vendor"
70
+ end
71
+
72
+ def self.column_names = %w[id name]
73
+ def self.primary_key = "id"
74
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
75
+ def self.find(_id) = new
76
+ def to_param = nil
77
+ def attributes = { "id" => id, "name" => name }
78
+ end
79
+
80
+ # PORO host resource (mirrors the RedirectFixtures/ReadOnlyResourceFixtures
81
+ # convention). Its index column returns each of the AR values above, and
82
+ # its show page renders an association table where one column is itself an
83
+ # AR value -- the two call sites Task 3 unifies (`render_column_value`'s
84
+ # fallback branch and `format_table_cell`'s AR branch).
85
+ class Deal
86
+ extend ActiveModel::Naming
87
+
88
+ attr_reader :id, :label, :company
89
+
90
+ def initialize(id:, label:, company:)
91
+ @id = id
92
+ @label = label
93
+ @company = company
94
+ end
95
+
96
+ def self.all = ReadOnlyResourceFixtures::Relation.new([
97
+ new(id: 1, label: "Q3 renewal", company: Company.new),
98
+ new(id: 2, label: "Raises on display", company: RaisingCo.new),
99
+ new(id: 3, label: "Unpersisted vendor", company: UnpersistedVendor.new),
100
+ new(id: 4, label: "No resource registered", company: UnregisteredVendor.new)
101
+ ])
102
+ def self.column_names = %w[id label]
103
+ def self.primary_key = "id"
104
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
105
+ def self.find(id) = all.find { |d| d.to_param == id.to_s }
106
+ def to_param = id.to_s
107
+ def attributes = { "id" => id, "label" => label }
108
+
109
+ def line_items
110
+ [
111
+ LineItem.new(1, "Renewal", Company.new),
112
+ LineItem.new(2, "Ghost line", UnregisteredVendor.new)
113
+ ]
114
+ end
115
+ end
116
+
117
+ LineItem = Struct.new(:id, :label, :company)
118
+
119
+ # Five deals, each pointing at a *distinct instance* of the *same*
120
+ # `LinkingFixtures::Company` class. `auto_admin_suite_path_for`'s registry
121
+ # lookup is keyed on `item.class`, not identity, so a correct memo
122
+ # resolves the resource once for this whole page -- pre-memo, each of
123
+ # the 5 rows independently re-ran the full registry scan.
124
+ class RepeatedCompanyDeal
125
+ extend ActiveModel::Naming
126
+
127
+ attr_reader :id, :label, :company
128
+
129
+ def initialize(id:, label:, company:)
130
+ @id = id
131
+ @label = label
132
+ @company = company
133
+ end
134
+
135
+ ROWS = Array.new(5) { |i| new(id: i + 1, label: "Deal #{i + 1}", company: Company.new(id: 100 + i, name: "Company #{i + 1}")) }
136
+
137
+ def self.all = ReadOnlyResourceFixtures::Relation.new(ROWS)
138
+ def self.column_names = %w[id label]
139
+ def self.primary_key = "id"
140
+ def self.columns_hash = { "id" => Struct.new(:type).new(:integer) }
141
+ def self.find(id) = all.find { |d| d.to_param == id.to_s }
142
+ def to_param = id.to_s
143
+ def attributes = { "id" => id, "label" => label }
144
+ end
145
+ end
146
+
147
+ module Admin
148
+ module Resources
149
+ class LinkingRepeatedCompanyDealResource < Admin::Base::Resource
150
+ model LinkingFixtures::RepeatedCompanyDeal
151
+ portal :ops
152
+ section :observability
153
+
154
+ index do
155
+ columns do
156
+ column :label
157
+ column :company
158
+ end
159
+ end
160
+ end
161
+
162
+ class LinkingRaisingCoResource < Admin::Base::Resource
163
+ model LinkingFixtures::RaisingCo
164
+ portal :ops
165
+ section :observability
166
+ end
167
+
168
+ class LinkingUnpersistedVendorResource < Admin::Base::Resource
169
+ model LinkingFixtures::UnpersistedVendor
170
+ portal :ops
171
+ section :observability
172
+ end
173
+
174
+ class LinkingDealResource < Admin::Base::Resource
175
+ model LinkingFixtures::Deal
176
+ portal :ops
177
+ section :observability
178
+
179
+ index do
180
+ columns do
181
+ column :label
182
+ column :company
183
+ end
184
+ # `Deal.all` is a `ReadOnlyResourceFixtures::Relation` -- it has no
185
+ # `#includes` method at all. This exercises Task 4's "skip
186
+ # silently, don't raise" path on every request this test file
187
+ # already makes to `/linking_deals`, not just a dedicated test.
188
+ includes :company
189
+ end
190
+
191
+ show do
192
+ section :line_items, association: :line_items, display: :table, columns: [ :label, :company ]
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ module AdminSuite
199
+ class AssociationLinkingTest < ActionDispatch::IntegrationTest
200
+ # ERB auto-escapes `<%= %>` output, so the literal bug string
201
+ # (`#<Company:0x...>`) never appears as raw `#<` in `response.body` --
202
+ # it shows up HTML-entity-escaped as `#&lt;LinkingFixtures::Company:0x...&gt;`.
203
+ # A `refute_includes response.body, "#<"` check would therefore pass
204
+ # vacuously against the *un*fixed code. Assert on the class-name leak
205
+ # itself instead, which survives escaping either way.
206
+ AR_INSPECT_LEAK = "LinkingFixtures::"
207
+
208
+ test "an index column returning an AR value links to that record's admin page" do
209
+ get "/internal/admin_suite/ops/linking_deals"
210
+ assert_response :success
211
+
212
+ refute_includes response.body, AR_INSPECT_LEAK
213
+ assert_includes response.body, "Acme Corp"
214
+ assert_match %r{<a[^>]+href="[^"]*linking_companies/7"[^>]*>\s*Acme Corp\s*</a>}, response.body
215
+ end
216
+
217
+ test "an AR value with a raising display title degrades instead of 500ing the page" do
218
+ get "/internal/admin_suite/ops/linking_deals"
219
+ assert_response :success
220
+ refute_includes response.body, AR_INSPECT_LEAK
221
+ end
222
+
223
+ test "an unpersisted AR record (to_param -> nil) renders plain text, not a broken link" do
224
+ get "/internal/admin_suite/ops/linking_deals"
225
+ assert_response :success
226
+ assert_includes response.body, "Draft Vendor"
227
+ refute_match %r{<a[^>]*>\s*Draft Vendor\s*</a>}, response.body
228
+ end
229
+
230
+ test "an AR value with no registered resource renders plain text, not a broken link" do
231
+ get "/internal/admin_suite/ops/linking_deals"
232
+ assert_response :success
233
+ assert_includes response.body, "Ghost Vendor"
234
+ refute_match %r{<a[^>]*>\s*Ghost Vendor\s*</a>}, response.body
235
+ end
236
+
237
+ test "an association table cell that is an AR value renders as a link too" do
238
+ get "/internal/admin_suite/ops/linking_deals/1"
239
+ assert_response :success
240
+
241
+ refute_includes response.body, AR_INSPECT_LEAK
242
+ assert_match %r{<a[^>]+href="[^"]*linking_companies/7"[^>]*>\s*Acme Corp\s*</a>}, response.body
243
+ # The second line item's company has no registered resource: plain text.
244
+ assert_includes response.body, "Ghost Vendor"
245
+ refute_match %r{<a[^>]*>\s*Ghost Vendor\s*</a>}, response.body
246
+ end
247
+
248
+ # Non-vacuous by construction: it counts actual invocations of
249
+ # `ensure_admin_resources_loaded_for!` -- called exactly once per
250
+ # registry *cache miss* inside `admin_suite_resource_for`, and nowhere
251
+ # else in the gem. (A page-wide spy on
252
+ # `Admin::Base::Resource.registered_resources` itself was tried first
253
+ # and rejected: navigation building calls it many more times per
254
+ # request for unrelated reasons -- e.g. `resources_for_portal` -- so
255
+ # that count is dominated by noise this task didn't touch.)
256
+ #
257
+ # Pre-memo, `auto_admin_suite_path_for` called
258
+ # `ensure_admin_resources_loaded_for!(item.class)` unconditionally on
259
+ # every invocation -- 5 rows of the same class meant 5 calls (and 2
260
+ # full registry scans apiece: the `.any?` inside it, plus the
261
+ # `registered_resources.find` right after). Memoized, only the first
262
+ # row's lookup is a cache miss; the other 4 hit
263
+ # `@admin_suite_resource_for` and never call this method at all -- so
264
+ # the call count for 5 rows must be 1, not 5. A test that only
265
+ # asserted "the memo hash is non-empty" would still pass if the memo
266
+ # were consulted but the scan ran unconditionally anyway; this asserts
267
+ # the scan was actually skipped.
268
+ test "auto_admin_suite_path_for memoizes the registry lookup per class, not per row" do
269
+ call_count = 0
270
+ helper_module = AdminSuite::BaseHelper
271
+ original_method = helper_module.instance_method(:ensure_admin_resources_loaded_for!)
272
+
273
+ helper_module.send(:define_method, :ensure_admin_resources_loaded_for!) do |model_class|
274
+ call_count += 1
275
+ original_method.bind(self).call(model_class)
276
+ end
277
+
278
+ begin
279
+ get "/internal/admin_suite/ops/linking_repeated_company_deals"
280
+ assert_response :success
281
+ assert_includes response.body, "Company 1"
282
+ assert_includes response.body, "Company 5"
283
+ ensure
284
+ helper_module.send(:define_method, :ensure_admin_resources_loaded_for!, original_method)
285
+ end
286
+
287
+ assert_equal 1, call_count,
288
+ "expected exactly one registry lookup for 5 rows of the same class (LinkingFixtures::Company) " \
289
+ "-- got #{call_count}, which means the memo isn't preventing a rescan per row"
290
+ end
291
+ end
292
+ end