mensa 0.6.17 → 0.6.19

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40f5c40722b8f985ed92ec054bffb1495379772be630b967fae55f6fc77bc8b9
4
- data.tar.gz: 1b5e668bb60387b99492c4eaf12f21fe548bdc913c4b6f9ecaa02dd3f8659b44
3
+ metadata.gz: b38d819bbd96ef6b2cb38e7b89c07c9a487eb7b92e8a191a92f4fd7e09cf1f7f
4
+ data.tar.gz: 52856b77830db55218a63213c4dc90a685686046175059cd78c53d8fb40d5527
5
5
  SHA512:
6
- metadata.gz: f36c28f26ae32c725cd0f2696e8fcd2bb57fe77cd75552f7daf24e4737e397eb91e8484a1d18b25cf6093cdef3e69a433e756e7815c10592dd29b635e4ee43f2
7
- data.tar.gz: 516d7dfe990004a70d878561f8697e46dada6323db733eeb640625a03ff43d7e006f08d7385aaaf89b84f306636cdea2d5d1b2796d5a23aa3ed4ee88d9939cd1
6
+ metadata.gz: a6dc8ccce0bed9be30b8b9c4b8d5f255b1d808d48a32c88e1813c40043378b0dd8d485abc5a34aa9b7b41f21335300f0f79f9cf6f7b1f1d7982f7f312027dad1
7
+ data.tar.gz: 36262b2128d07c1919dee7c08a82f0d15af44be791b012757afe41c28dadf5aafcc75e3be4907c0d9369d319217273297c98a20e2ab15380c03766da7b2e43ee
data/.shipkit.yml CHANGED
@@ -1,2 +1,2 @@
1
1
  ---
2
- previous_version: 0.6.16
2
+ previous_version: 0.6.18
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.19
4
+
5
+ - `Mensa::Verifier` checks a table against the database: scope, column SQL,
6
+ sorting, rendering, filters, search, CSV export, grouping/aggregates and
7
+ views. Run it with `bin/rails "mensa:verify[table]"` / `mensa:verify_all`,
8
+ or from tests after `require "mensa/testing"` with
9
+ `assert_mensa_table_valid` or `be_a_valid_mensa_table`. Set defaults for
10
+ its context with `config.verifier_context`.
11
+ - The verifier samples records from the first and last page and records with
12
+ empty values, tries filters with invalid input, checks search, filters, sorting
13
+ and grouping together, and warns about N+1 queries (with a fix) and slow
14
+ queries. `strict: true` / `STRICT=1` makes warnings fail.
15
+ `config.verifier_context` can return an array to verify tables in several
16
+ contexts, `config.verifier_slow_query` sets when a query is slow.
17
+ - Columns without SQL (only a `render` block or a `method`, no `attribute` or
18
+ database column) are no longer sortable or filterable by default, sorting or
19
+ filtering on them produced invalid SQL. Use `sortable true` for a column whose
20
+ name is an alias the scope selects, or a filter `scope` to filter it.
21
+ - Filters with a value their column's type can't hold (e.g. "abc" for a number,
22
+ or an invalid date or uuid) match nothing instead of raising a database error.
23
+ - `Mensa::ExportJob.write_csv(io, table, rows)` writes a table's CSV export and
24
+ `Mensa::ExportJob#generate_file(export)` generates an export's file without
25
+ saving it.
26
+ - Exports of tables with a `csv` render block no longer fail: the export job
27
+ renders with a view context of `config.base_controller`
28
+ (`Mensa.offline_view_context`).
29
+ - Row actions without an `icon` show their title instead of raising.
30
+ - An unknown helper in a render block raises `NoMethodError` instead of
31
+ `SystemStackError`. Only the app's `*_path`/`*_url` helpers are forwarded to
32
+ `main_app`.
33
+ - Search leaves out columns with a `having` filter (aggregates), which broke the
34
+ query.
35
+ - `is_duplicate` works for columns with a `having` filter.
36
+
3
37
  ## 0.6.16
4
38
 
5
39
  - Custom filter `scope` blocks can take the operator as a second argument,
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mensa (0.6.16)
4
+ mensa (0.6.18)
5
5
  csv
6
6
  importmap-rails
7
7
  pagy (>= 43)
data/README.md CHANGED
@@ -118,11 +118,49 @@ You can show your tables on the page using the following:
118
118
  Custom views are views not defined by the developer (SystemViews) but by the end-user by adding/removing filters.
119
119
  When you enable these, they are stored in the database and can be used across sessions, by the user who created them.
120
120
 
121
+ ### Sortable and filterable columns
122
+
123
+ Sorting and filtering happen in SQL, so whether a column can be sorted or filtered
124
+ depends on whether it has SQL: a database column of the model, or an `attribute`.
125
+
126
+ - **Columns with SQL** are sortable and filterable by default. Turn that off with
127
+ `sortable false` or `filter false`.
128
+ - **Columns without SQL**, which only show something with a `render` block or a
129
+ `method`, are *not* sortable or filterable by default. Their headers have no sort
130
+ link, they aren't offered in the filter menu, and sorting or filtering on them
131
+ from a URL or saved state is ignored.
132
+
133
+ ```ruby
134
+ column(:email) # sortable and filterable
135
+ column(:customer_name) do # sortable and filterable, it has SQL
136
+ attribute "customers.name"
137
+ end
138
+ column(:avatar) do # render only: not sortable, not filterable
139
+ render do
140
+ html { |user| image_tag(user.avatar_url) }
141
+ end
142
+ end
143
+ ```
144
+
145
+ To make a column without SQL filterable, give its filter a `scope` (see "Custom
146
+ filter scopes" below). Add `sortable true` only when its name can be sorted on in
147
+ SQL, e.g. an alias your table's `scope` selects:
148
+
149
+ ```ruby
150
+ column(:orders_count) do
151
+ sortable true # ORDER BY orders_count, selected in the scope below
152
+ end
153
+
154
+ scope { User.left_joins(:orders).group(:id).select("users.*, COUNT(orders.id) AS orders_count") }
155
+ ```
156
+
157
+ Better still, give such a column an `attribute`, so it's sortable and filterable.
158
+
121
159
  ### Filters
122
160
 
123
- Add `filter` to a column to let users filter on it. By default Mensa compares the
124
- column's attribute with the chosen value, using operators that fit the column type.
125
- The filter block takes these options:
161
+ Columns with SQL are filterable (see above). Use `filter` to configure how. By
162
+ default Mensa compares the column's attribute with the chosen value, using
163
+ operators that fit the column type. The filter block takes these options:
126
164
 
127
165
  ```ruby
128
166
  column(:role) do
@@ -320,6 +358,115 @@ If you're just using cron, you can add the following to your crontab:
320
358
  0 0 * * * rails runner "Mensa::RecurringExportsJob.perform_later"
321
359
  ```
322
360
 
361
+ ### Verifying tables
362
+
363
+ `Mensa::Verifier` checks that everything a table configures works against your
364
+ real database, using the same code paths as requests do:
365
+
366
+ - **Loads**: the table builds, its SQL builds and the first and last pages load. A
367
+ default `order` or `column_order` naming an unknown column fails. Records are
368
+ sampled for the checks below: some from the first and the last page, and one with
369
+ an empty value for each column, which often breaks render blocks.
370
+ - **Columns**: every column (internal ones too) can be selected and, when sortable,
371
+ sorted both ways. Its html and csv renders run for the sampled records, and a
372
+ failure names the record. Columns hidden by a `visible` lambda are reported as
373
+ skipped.
374
+ - **Filters**: every filterable column is applied with each of its operators, using
375
+ the first values of its `collection` or a value that fits the column's type, and
376
+ with input the column's type can't hold (e.g. "not a number"). The filter pill's
377
+ text is built. `is_current` is skipped when `Current` has no value for the column.
378
+ - **Search**: basic and fuzzy search (when `pg_trgm` is enabled) for a word taken
379
+ from the records.
380
+ - **Rendering**: the `table` helper as your views call it, the rows and filter pills
381
+ as Mensa's controller renders them, the row link and every row action.
382
+ - **Exports**: `Mensa::ExportJob` generates the first page of the export, also for
383
+ each view and as a password protected zip. It needs the expected headers
384
+ (skipped for `exportable false`).
385
+ - **Combined**: search, a filter on every column, sorting on every column and
386
+ grouping with aggregates, all at once.
387
+ - **Grouping**: every groupable column and every aggregate, rendered with their
388
+ group headers. A `group_by` or `aggregates` naming a column that doesn't allow
389
+ it fails, because Mensa would silently ignore it.
390
+ - **Views**: system views load, render and only refer to existing columns. With
391
+ custom views enabled, saved views load too.
392
+
393
+ It also warns, without failing:
394
+
395
+ - **N+1 queries**: a query that a render block, the row link or a row action runs for
396
+ each record. The warning shows the query and how to avoid it for the association
397
+ it loads, e.g. preload it with `includes` (and `internal :customer_id`, since Mensa
398
+ only selects the table's columns), select the value in SQL with `attribute`, or
399
+ count in SQL.
400
+ - **Slow queries**: queries that take `config.verifier_slow_query` seconds (1.0 by
401
+ default) or more.
402
+
403
+ Pass `strict: true` (or `STRICT=1` to rake) to make warnings fail.
404
+
405
+ Each check runs on its own, so one failure doesn't hide the others. Everything runs
406
+ in a read-only transaction that is rolled back, with small limits, so it is safe to
407
+ run against staging. It never writes.
408
+
409
+ #### From rake
410
+
411
+ ```sh
412
+ bin/rails "mensa:verify[inventories]" # or InventoriesTable
413
+ bin/rails mensa:verify_all # every table in app/tables
414
+ ```
415
+
416
+ The tasks print a report (✓ passed, ✗ failed with the error and SQL, - skipped) and
417
+ exit non-zero when a check failed. Classes and modules that don't descend from
418
+ `Mensa::Base` (e.g. helpers in `app/tables`) are reported as skipped.
419
+
420
+ #### From tests
421
+
422
+ ```ruby
423
+ # test/test_helper.rb or spec/rails_helper.rb
424
+ require "mensa/testing"
425
+
426
+ # Minitest
427
+ assert_mensa_table_valid InventoriesTable, user: users(:admin)
428
+
429
+ # RSpec
430
+ expect(InventoriesTable).to be_a_valid_mensa_table(user: admin)
431
+
432
+ # Plain Ruby
433
+ result = Mensa::Verifier.new(InventoriesTable, user: admin).call
434
+ result.success? # => true
435
+ result.failures # => [#<struct Mensa::Verifier::Check section: :filters, subject: :status, ...>]
436
+ puts result # the report, also the failure message of the helpers
437
+ ```
438
+
439
+ #### Context
440
+
441
+ Tables often depend on the request: `Current.user`, policy scopes, or the table's
442
+ params. Give the verifier that context:
443
+
444
+ - `user:` and `account:` set `Current.user` and `Current.account` while verifying.
445
+ - `context: {organization: org}` sets any other `Current` attributes.
446
+ - `params: {customer_id: 1}` sets the table's params (see "Table params").
447
+ - A block is called with the verifier before the checks, for any other setup.
448
+ - `label: "admin"` names the context in the report.
449
+
450
+ Html render blocks run in a view context of your `ApplicationController` (see
451
+ `config.base_controller`), so url helpers and your helpers work.
452
+
453
+ From rake, set `USER_ID=` (finds a `User`), `ACCOUNT_ID=` (finds an `Account`) and
454
+ `PARAMS='{"customer_id":1}'` (JSON), or configure defaults for every table in
455
+ your initializer. The options you pass yourself override these defaults. Return an array of them to
456
+ verify every table once for each, e.g. as different kinds of users:
457
+
458
+ ```ruby
459
+ Mensa.setup do |config|
460
+ config.verifier_context = ->(table_class) {
461
+ params = {customer_id: Customer.first.id} if table_class == CustomerUsersTable
462
+ [
463
+ {label: "admin", user: User.find_by!(email: "admin@example.com"), params: params},
464
+ {label: "customer", user: User.find_by!(email: "customer@example.com"), params: params}
465
+ ]
466
+ }
467
+ end
468
+ ```
469
+
323
470
  ## Contributing
324
471
 
325
472
  ```
@@ -4,7 +4,7 @@
4
4
  <% if icon = action.icon(row.record) %>
5
5
  <i class="fa <%= icon %>"></i>
6
6
  <% else %>
7
- <%= name %>
7
+ <%= action.title %>
8
8
  <% end %>
9
9
  <% end %>
10
10
  <% end %>
@@ -6,16 +6,21 @@ module Mensa
6
6
  render(component)
7
7
  end
8
8
 
9
+ # The app's url helpers, also inside Mensa's engine views
9
10
  def respond_to_missing?(method, include_private = false)
10
- if method.to_s.ends_with?("_url") || method.to_s.ends_with?("_path") && main_app.respond_to?(method)
11
- true
12
- else
13
- super
14
- end
11
+ main_app_url_helper?(method) || super
15
12
  end
16
13
 
17
14
  def method_missing(method, *args, **kwargs, &block)
18
- main_app.send(method, *args, **kwargs, &block)
15
+ return super unless main_app_url_helper?(method)
16
+
17
+ main_app.public_send(method, *args, **kwargs, &block)
18
+ end
19
+
20
+ private
21
+
22
+ def main_app_url_helper?(method)
23
+ method.to_s.end_with?("_url", "_path") && main_app.respond_to?(method)
19
24
  end
20
25
  end
21
26
  end
@@ -9,20 +9,30 @@ module Mensa
9
9
  class ExportJob < ApplicationJob
10
10
  queue_as :default
11
11
 
12
+ # Writes the CSV for +rows+ (Mensa::Row) of +table+ to +io+: a header row
13
+ # with the display column names, then one line per row.
14
+ def self.write_csv(io, table, rows)
15
+ csv = CSV.new(io)
16
+ csv << table.display_columns.map(&:name)
17
+ rows.each do |row|
18
+ csv << table.display_columns.map { |column| Mensa::Cell.new(row: row, column: column).render(:csv) }
19
+ end
20
+ csv.flush
21
+ io
22
+ end
23
+
12
24
  def perform(export)
13
25
  return unless export
14
26
 
15
27
  export.update(status: "processing")
16
28
  Mensa.config.run_callback(:export_started, export)
17
29
 
18
- table = build_table(export)
19
- unless table.exportable?
30
+ tempfile, filename, content_type = generate_file(export)
31
+ unless tempfile
20
32
  finalize(export, status: "failed")
21
33
  return
22
34
  end
23
35
 
24
- tempfile, filename, content_type = generate(table, export)
25
-
26
36
  export.asset.purge if export.asset.attached?
27
37
  export.asset.attach(io: tempfile, filename: filename, content_type: content_type)
28
38
  finalize(export, status: "completed", filename: filename)
@@ -36,6 +46,16 @@ module Mensa
36
46
  tempfile&.close!
37
47
  end
38
48
 
49
+ # Builds the table for +export+ and generates its file. Returns
50
+ # [tempfile, filename, content_type], or nil when the table isn't
51
+ # exportable. Doesn't touch the export's record or asset.
52
+ def generate_file(export)
53
+ table = build_table(export)
54
+ return unless table.exportable?
55
+
56
+ generate(table, export)
57
+ end
58
+
39
59
  private
40
60
 
41
61
  # Rebuilds the table the export was requested for, layering the view
@@ -54,6 +74,8 @@ module Mensa
54
74
  table = Mensa.for_name(export.table_name, config)
55
75
  table.request = ActionDispatch::Request.new({})
56
76
  table.request.set_header("action_dispatch.request.query_parameters", {"page" => config[:page]})
77
+ # Render blocks (e.g. csv) run in a view context
78
+ table.original_view_context = Mensa.offline_view_context
57
79
  table
58
80
  end
59
81
 
@@ -78,12 +100,8 @@ module Mensa
78
100
  # with programmatic consumers.
79
101
  tempfile.write("\uFEFF") if export.format == "csv_excel"
80
102
 
81
- csv = CSV.new(tempfile)
82
- csv << table.display_columns.map(&:name)
83
- export_rows(table, export).each do |row|
84
- csv << table.display_columns.map { |column| Mensa::Cell.new(row: row, column: column).render(:csv) }
85
- end
86
- csv.close
103
+ self.class.write_csv(tempfile, table, export_rows(table, export))
104
+ tempfile.close
87
105
  tempfile.open
88
106
  tempfile.binmode
89
107
  tempfile.rewind
@@ -110,7 +110,9 @@ module Mensa
110
110
  def active_filters
111
111
  (config[:filters] || {}).filter_map do |column_name, filter_config|
112
112
  col = column(column_name)
113
- next unless col
113
+ # Columns without SQL can only be filtered with a custom scope, skip
114
+ # filters on them, e.g. from browser state saved earlier
115
+ next if col.nil? || !(col.sql? || col.filter?)
114
116
 
115
117
  definition = config.dig(:columns, col.name, :filter)
116
118
  definition = {} unless definition.is_a?(Hash)
@@ -14,7 +14,6 @@ module Mensa
14
14
  @table = table
15
15
  end
16
16
 
17
- config_reader :sortable?
18
17
  config_reader :sanitize?
19
18
  config_reader :visible?
20
19
  config_reader :internal?
@@ -96,9 +95,25 @@ module Mensa
96
95
  aggregates.any?
97
96
  end
98
97
 
99
- # Returns true if the column supports filtering
98
+ # Whether the column has SQL (an attribute or a database column). Columns
99
+ # without, e.g. only a render block or a method, can't be sorted or
100
+ # filtered in SQL.
101
+ def sql?
102
+ attribute_for_condition.present?
103
+ end
104
+
105
+ # Columns with SQL are sortable unless `sortable false`, others only with
106
+ # an explicit `sortable true`
107
+ def sortable?
108
+ config[:sortable].nil? ? sql? : !!config[:sortable]
109
+ end
110
+
111
+ # Returns true if the column supports filtering: columns with SQL unless
112
+ # `filter false`, others only with a filter that has its own scope
100
113
  def filter?
101
- config[:filter] != false
114
+ return false if config[:filter] == false
115
+
116
+ sql? || (config[:filter].is_a?(Hash) && config[:filter][:scope].present?)
102
117
  end
103
118
 
104
119
  def filter
@@ -4,7 +4,8 @@ module Mensa::Config
4
4
  class ColumnDsl
5
5
  include DslLogic
6
6
 
7
- option :sortable, default: true
7
+ # Defaults to whether the column has SQL, see Mensa::Column#sortable?
8
+ option :sortable
8
9
  option :sanitize, default: true
9
10
  # Allows for sql-parts too
10
11
  #
@@ -86,6 +86,9 @@ module Mensa
86
86
 
87
87
  # Nothing selected in a multi-select means no filter
88
88
  return record_scope if value.is_a?(Array) && value.compact_blank.empty?
89
+ # A value the database can't compare with the column, e.g. "abc" for
90
+ # a number, matches nothing instead of raising
91
+ return record_scope.none if operator_with_value? && invalid_value?
89
92
 
90
93
  query, hash = query_and_hash_for_operator
91
94
  record_scope = (having? ? record_scope.having(query, hash) : record_scope.where(query, hash)) if query.present?
@@ -211,6 +214,7 @@ module Mensa
211
214
  def apply_duplicate_filter(record_scope)
212
215
  column_sql = column.attribute_for_condition.to_s
213
216
  base_scope = record_scope.except(:select, :order, :limit, :offset)
217
+ return apply_duplicate_having_filter(record_scope, base_scope, column_sql) if having?
214
218
 
215
219
  duplicate_values_scope = base_scope
216
220
  .where("#{column_sql} IS NOT NULL")
@@ -223,6 +227,42 @@ module Mensa
223
227
  .where("#{column_sql} IN (#{duplicate_values_scope.to_sql})")
224
228
  end
225
229
 
230
+ UUID_FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
231
+
232
+ # Whether a value can't be read as the column's type. Values are sent to
233
+ # the database as they are, this only catches the ones it would reject.
234
+ def invalid_value?
235
+ Array(value).compact_blank.any? { |entry| !valid_for_type?(entry) }
236
+ end
237
+
238
+ def valid_for_type?(entry)
239
+ return true unless entry.is_a?(String)
240
+
241
+ case column.type
242
+ when :integer, :bigint then entry.strip.match?(/\A[+-]?\d+\z/)
243
+ when :decimal, :float then entry.strip.match?(/\A[+-]?(\d+\.?\d*|\.\d+)\z/)
244
+ when :date then Date.parse(entry).present?
245
+ when :datetime, :timestamp then Time.zone.parse(entry).present?
246
+ when :uuid then entry.strip.match?(UUID_FORMAT)
247
+ else true
248
+ end
249
+ rescue ArgumentError, RangeError
250
+ false
251
+ end
252
+
253
+ # Aggregates are only known per group, so the duplicate values come from
254
+ # a subquery of the grouped rows and are compared in the HAVING
255
+ def apply_duplicate_having_filter(record_scope, base_scope, column_sql)
256
+ duplicate_values_scope = base_scope.model.unscoped
257
+ .from(base_scope.select(Arel.sql("#{column_sql} AS mensa_duplicate_value")), :mensa_values)
258
+ .where("mensa_values.mensa_duplicate_value IS NOT NULL")
259
+ .group("mensa_values.mensa_duplicate_value")
260
+ .having("COUNT(*) > 1")
261
+ .select("mensa_values.mensa_duplicate_value")
262
+
263
+ record_scope.having("#{column_sql} IN (#{duplicate_values_scope.to_sql})")
264
+ end
265
+
226
266
  # Unused at the moment
227
267
  def normalize(query)
228
268
  query
@@ -129,6 +129,9 @@ module Mensa
129
129
  # Skip orders on unknown names, e.g. from browser state saved for
130
130
  # different params.
131
131
  next unless col || model_attribute?(k)
132
+ # Columns without SQL can't be sorted on, unless they're explicitly
133
+ # sortable, e.g. on an alias the scope selects
134
+ next if col && !col.sql? && !col.sortable?
132
135
  next unless %w[asc desc].include?(v)
133
136
 
134
137
  attribute = col&.attribute_for_condition || k
@@ -10,7 +10,8 @@ module Mensa
10
10
  return record_scope if query.blank?
11
11
  return record_scope unless record_scope.is_a?(ActiveRecord::Relation)
12
12
 
13
- searchable_attributes = columns.filter_map(&:attribute_for_condition).uniq
13
+ # Aggregates (filter having) can't be in a WHERE
14
+ searchable_attributes = columns.reject { |column| column.filter&.having? }.filter_map(&:attribute_for_condition).uniq
14
15
  return record_scope if searchable_attributes.empty?
15
16
 
16
17
  @search_order_clause = nil
@@ -7,6 +7,10 @@ Mensa.setup do |config|
7
7
  end
8
8
  }
9
9
 
10
+ # Context for `rails mensa:verify` and the test helpers (user:, account:,
11
+ # context:, params:)
12
+ # config.verifier_context = ->(table_class) { {user: User.find_by!(email: "admin@example.com")} }
13
+
10
14
  # Override icons in use
11
15
  config.icons = {
12
16
  add_filter_trigger: "fa-solid fa-circle-plus",
@@ -144,6 +144,15 @@ module Mensa
144
144
  option :row_actions_position, default: :back
145
145
  # It's either :basic or :fuzzy, for fuzzy search you need to have `pg_trgm` extension installed
146
146
  option :search, default: :basic
147
+ # Default options for Mensa::Verifier (and so `rails mensa:verify`), called
148
+ # with the table class. Return a hash with any of user:, account:,
149
+ # context: (Current attributes), params: and label:, or an array of them
150
+ # to verify the table in each of those contexts, e.g.
151
+ #
152
+ # config.verifier_context = ->(table_class) { {user: User.find_by!(email: "admin@example.com")} }
153
+ option :verifier_context, default: nil
154
+ # Mensa::Verifier warns about queries that take this many seconds or more
155
+ option :verifier_slow_query, default: 1.0
147
156
 
148
157
  def initialize
149
158
  set_defaults!
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mensa/verifier"
4
+
5
+ module Mensa
6
+ # Test helpers on top of Mensa::Verifier, opt in with `require "mensa/testing"`.
7
+ #
8
+ # assert_mensa_table_valid UsersTable, user: users(:admin) # Minitest
9
+ # expect(UsersTable).to be_a_valid_mensa_table(user: admin) # RSpec
10
+ #
11
+ # Both take the verifier's options (user:, account:, context:, params:,
12
+ # strict:), verify the table in every context of
13
+ # Mensa.config.verifier_context and fail with the reports.
14
+ module Testing
15
+ module Assertions
16
+ def assert_mensa_table_valid(table, message = nil, **options, &setup)
17
+ results = Mensa::Verifier.verify(table, **options, &setup)
18
+ assert results.all?(&:success?), -> { [message, *results.reject(&:success?).map(&:to_s)].compact.join("\n\n") }
19
+ results
20
+ end
21
+ end
22
+
23
+ class ValidTableMatcher
24
+ attr_reader :results
25
+
26
+ def initialize(**options, &setup)
27
+ @options = options
28
+ @setup = setup
29
+ end
30
+
31
+ def matches?(table)
32
+ @results = Mensa::Verifier.verify(table, **@options, &@setup)
33
+ @results.all?(&:success?)
34
+ end
35
+
36
+ def failure_message
37
+ results.reject(&:success?).join("\n\n")
38
+ end
39
+
40
+ def failure_message_when_negated
41
+ "expected #{results.first.table_class} not to be a valid Mensa table, but all its checks passed"
42
+ end
43
+
44
+ def description
45
+ "be a valid Mensa table"
46
+ end
47
+ end
48
+
49
+ module Matchers
50
+ def be_a_valid_mensa_table(**options, &setup)
51
+ ValidTableMatcher.new(**options, &setup)
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ if defined?(Minitest::Test)
58
+ Minitest::Test.include(Mensa::Testing::Assertions)
59
+ else
60
+ ActiveSupport.on_load(:active_support_test_case) { include Mensa::Testing::Assertions }
61
+ end
62
+
63
+ RSpec.configure { |config| config.include(Mensa::Testing::Matchers) } if defined?(RSpec) && RSpec.respond_to?(:configure)