trakable 0.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.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +81 -0
  3. data/CHANGELOG.md +50 -0
  4. data/LICENSE +21 -0
  5. data/README.md +330 -0
  6. data/Rakefile +16 -0
  7. data/benchmark/full_benchmark.rb +221 -0
  8. data/benchmark/integration_memory.rb +70 -0
  9. data/benchmark/memory_benchmark.rb +141 -0
  10. data/benchmark/perf_benchmark.rb +130 -0
  11. data/integration/README.md +65 -0
  12. data/integration/run_all.rb +62 -0
  13. data/integration/scenarios/01-basic-tracking/scenario.rb +51 -0
  14. data/integration/scenarios/02-revert-restoration/scenario.rb +103 -0
  15. data/integration/scenarios/03-whodunnit-tracking/scenario.rb +72 -0
  16. data/integration/scenarios/04-cleanup-retention/scenario.rb +66 -0
  17. data/integration/scenarios/05-without-tracking/scenario.rb +62 -0
  18. data/integration/scenarios/06-callback-lifecycle/scenario.rb +103 -0
  19. data/integration/scenarios/07-global-config/scenario.rb +52 -0
  20. data/integration/scenarios/08-controller-integration/scenario.rb +44 -0
  21. data/integration/scenarios/09-cleanup-max-traks/scenario.rb +58 -0
  22. data/integration/scenarios/10-model-configuration/scenario.rb +68 -0
  23. data/integration/scenarios/11-conditional-tracking/scenario.rb +48 -0
  24. data/integration/scenarios/12-metadata/scenario.rb +54 -0
  25. data/integration/scenarios/13-traks-association/scenario.rb +80 -0
  26. data/integration/scenarios/14-time-travel/scenario.rb +132 -0
  27. data/integration/scenarios/15-diffing-changeset/scenario.rb +109 -0
  28. data/integration/scenarios/16-serialization/scenario.rb +159 -0
  29. data/integration/scenarios/17-associations-tracking/scenario.rb +143 -0
  30. data/integration/scenarios/18-bulk-operations/scenario.rb +70 -0
  31. data/integration/scenarios/19-transactions/scenario.rb +89 -0
  32. data/integration/scenarios/20-performance/scenario.rb +89 -0
  33. data/integration/scenarios/21-storage-backends/scenario.rb +52 -0
  34. data/integration/scenarios/22-multi-tenancy/scenario.rb +49 -0
  35. data/integration/scenarios/23-sti/scenario.rb +58 -0
  36. data/integration/scenarios/24-edge-cases-part1/scenario.rb +86 -0
  37. data/integration/scenarios/25-edge-cases-part2/scenario.rb +74 -0
  38. data/integration/scenarios/26-edge-cases-part3/scenario.rb +76 -0
  39. data/integration/scenarios/27-api-query-interface/scenario.rb +78 -0
  40. data/integration/scenarios/28-security-compliance/scenario.rb +61 -0
  41. data/integration/scenarios/29-soft-delete/scenario.rb +43 -0
  42. data/integration/scenarios/30-custom-events/scenario.rb +45 -0
  43. data/integration/scenarios/31-gem-packaging/scenario.rb +58 -0
  44. data/integration/scenarios/32-bypass-fail-closed/scenario.rb +77 -0
  45. data/integration/scenarios/33-coexistence-standalone/scenario.rb +53 -0
  46. data/integration/scenarios/34-real-tracking/scenario.rb +254 -0
  47. data/integration/scenarios/35-revert-undo/scenario.rb +235 -0
  48. data/integration/scenarios/36-whodunnit-deep/scenario.rb +281 -0
  49. data/integration/scenarios/37-real-world-use-cases/scenario.rb +1213 -0
  50. data/integration/scenarios/38-concurrency/scenario.rb +163 -0
  51. data/integration/scenarios/39-query-scopes/scenario.rb +126 -0
  52. data/integration/scenarios/40-whodunnit-config/scenario.rb +113 -0
  53. data/integration/scenarios/41-batch-cleanup/scenario.rb +186 -0
  54. data/integration/scenarios/scenario_runner.rb +68 -0
  55. data/lib/generators/trakable/install_generator.rb +28 -0
  56. data/lib/generators/trakable/templates/create_traks_migration.rb +23 -0
  57. data/lib/generators/trakable/templates/trakable_initializer.rb +15 -0
  58. data/lib/trakable/cleanup.rb +89 -0
  59. data/lib/trakable/config.rb +22 -0
  60. data/lib/trakable/context.rb +85 -0
  61. data/lib/trakable/controller.rb +25 -0
  62. data/lib/trakable/model.rb +99 -0
  63. data/lib/trakable/railtie.rb +28 -0
  64. data/lib/trakable/revertable.rb +166 -0
  65. data/lib/trakable/tracker.rb +134 -0
  66. data/lib/trakable/trak.rb +98 -0
  67. data/lib/trakable/version.rb +5 -0
  68. data/lib/trakable.rb +51 -0
  69. data/trakable.gemspec +41 -0
  70. metadata +242 -0
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Unified Benchmark for Trakable
5
+ # Measures 5 dimensions in one run: Boot, Speed, Memory, Storage, Integration
6
+ #
7
+ # Usage:
8
+ # ruby benchmark/full_benchmark.rb
9
+ # ruby benchmark/full_benchmark.rb > /tmp/before.txt
10
+ # # ... apply changes ...
11
+ # ruby benchmark/full_benchmark.rb > /tmp/after.txt
12
+ # diff /tmp/before.txt /tmp/after.txt
13
+
14
+ ENV['DISABLE_COVERAGE'] = '1'
15
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
16
+
17
+ require 'benchmark'
18
+ require 'json'
19
+ require 'trakable'
20
+
21
+ SPEED_ITERATIONS = 50_000
22
+ SPEED_RUNS = 5
23
+ ALLOC_ITERATIONS = 10_000
24
+
25
+ results = {}
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # A. Boot — subprocess `ruby -e "require 'trakable'"` × 5, median
29
+ # ---------------------------------------------------------------------------
30
+ boot_times = 5.times.map do
31
+ cmd = %(ruby -I#{File.expand_path('../lib', __dir__)} -e "require 'trakable'")
32
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
+ system(cmd, out: File::NULL, err: File::NULL)
34
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
+ ((t1 - t0) * 1_000_000).round(0)
36
+ end.sort
37
+ results['boot_time_us'] = boot_times[2] # median
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Mocks
41
+ # ---------------------------------------------------------------------------
42
+ class BenchRecord
43
+ attr_accessor :trakable_options, :previous_changes
44
+
45
+ def initialize
46
+ @trakable_options = { only: %w[title body], ignore: %w[status] }
47
+ @previous_changes = {
48
+ 'title' => %w[OldTitle NewTitle],
49
+ 'body' => %w[OldBody NewBody],
50
+ 'views' => [0, 10],
51
+ 'updated_at' => [Time.now - 3600, Time.now],
52
+ 'status' => %w[draft published]
53
+ }
54
+ end
55
+
56
+ def id = 1
57
+ def self.name = 'BenchRecord'
58
+
59
+ def attributes
60
+ { 'id' => 1, 'title' => 'NewTitle', 'body' => 'NewBody',
61
+ 'views' => 10, 'status' => 'published' }
62
+ end
63
+ end
64
+
65
+ class WideRecord
66
+ attr_accessor :trakable_options, :previous_changes
67
+
68
+ def initialize
69
+ @trakable_options = {}
70
+ @previous_changes = {
71
+ 'field_03' => %w[old new],
72
+ 'field_07' => %w[old new]
73
+ }
74
+ end
75
+
76
+ def id = 2
77
+ def self.name = 'WideRecord'
78
+
79
+ def attributes
80
+ h = { 'id' => 2 }
81
+ 20.times { |i| h["field_#{format('%02d', i)}"] = "value_#{i}" }
82
+ h
83
+ end
84
+ end
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # B. Speed — Benchmark.realtime × SPEED_ITERATIONS × SPEED_RUNS, median
88
+ # ---------------------------------------------------------------------------
89
+ def median_speed(record, event)
90
+ # warmup
91
+ 100.times { Trakable::Tracker.call(record, event) }
92
+
93
+ runs = SPEED_RUNS.times.map do
94
+ t = Benchmark.realtime { SPEED_ITERATIONS.times { Trakable::Tracker.call(record, event) } }
95
+ (t / SPEED_ITERATIONS * 1_000_000).round(2)
96
+ end.sort
97
+ runs[2]
98
+ end
99
+
100
+ bench = BenchRecord.new
101
+ results['speed_create_us'] = median_speed(bench, 'create')
102
+ results['speed_update_us'] = median_speed(bench, 'update')
103
+ results['speed_destroy_us'] = median_speed(bench, 'destroy')
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # C. Memory — GC.stat[:total_allocated_objects] × ALLOC_ITERATIONS
107
+ # ---------------------------------------------------------------------------
108
+ def measure_allocs(record, event)
109
+ # warmup
110
+ 100.times { Trakable::Tracker.call(record, event) }
111
+
112
+ GC.start
113
+ GC.disable
114
+ before = GC.stat[:total_allocated_objects]
115
+ ALLOC_ITERATIONS.times { Trakable::Tracker.call(record, event) }
116
+ after = GC.stat[:total_allocated_objects]
117
+ GC.enable
118
+ ((after - before).to_f / ALLOC_ITERATIONS).round(1)
119
+ end
120
+
121
+ def measure_allocs_breakdown(record, event)
122
+ 100.times { Trakable::Tracker.call(record, event) }
123
+
124
+ GC.start
125
+ GC.disable
126
+ before = ObjectSpace.count_objects.dup
127
+ ALLOC_ITERATIONS.times { Trakable::Tracker.call(record, event) }
128
+ after = ObjectSpace.count_objects
129
+ GC.enable
130
+
131
+ result = {}
132
+ after.each do |type, count|
133
+ diff = count - (before[type] || 0)
134
+ result[type] = (diff.to_f / ALLOC_ITERATIONS).round(1) if diff > 0
135
+ end
136
+ result
137
+ end
138
+
139
+ bench = BenchRecord.new
140
+ results['allocs_create'] = measure_allocs(bench, 'create')
141
+ results['allocs_update'] = measure_allocs(bench, 'update')
142
+ results['allocs_destroy'] = measure_allocs(bench, 'destroy')
143
+
144
+ breakdown = measure_allocs_breakdown(bench, 'update')
145
+ breakdown.each do |type, val|
146
+ results["allocs_update_#{type}"] = val
147
+ end
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # D. Storage — JSON.generate(trak.object).bytesize
151
+ # ---------------------------------------------------------------------------
152
+ def storage_metrics(record, event, prefix)
153
+ trak = Trakable::Tracker.call(record, event)
154
+ result = {}
155
+ obj = trak.object
156
+ cs = trak.changeset
157
+
158
+ result["#{prefix}object_bytes"] = obj ? JSON.generate(obj).bytesize : 0
159
+ result["#{prefix}changeset_bytes"] = cs && !cs.empty? ? JSON.generate(cs).bytesize : 0
160
+ result["#{prefix}total_bytes"] = result["#{prefix}object_bytes"] + result["#{prefix}changeset_bytes"]
161
+ result
162
+ end
163
+
164
+ bench = BenchRecord.new
165
+ results.merge!(storage_metrics(bench, 'update', 'storage_'))
166
+ results.merge!(storage_metrics(bench, 'destroy', 'storage_destroy_'))
167
+
168
+ wide = WideRecord.new
169
+ results.merge!(storage_metrics(wide, 'update', 'storage_wide_'))
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # E. Integration — Load scenarios with GC counting
173
+ # ---------------------------------------------------------------------------
174
+ scenarios_dir = File.expand_path('../integration/scenarios', __dir__)
175
+ if Dir.exist?(scenarios_dir)
176
+ scenario_dirs = Dir.glob("#{scenarios_dir}/*").select { |d| File.directory?(d) }.sort
177
+ total_allocs = 0
178
+ scenario_count = 0
179
+
180
+ real_stdout = $stdout
181
+
182
+ scenario_dirs.each do |dir|
183
+ scenario_file = File.join(dir, 'scenario.rb')
184
+ next unless File.exist?(scenario_file)
185
+
186
+ Trakable::Context.reset!
187
+ $stdout = File.open(File::NULL, 'w')
188
+
189
+ GC.start
190
+ GC.disable
191
+ before = GC.stat[:total_allocated_objects]
192
+
193
+ begin
194
+ load scenario_file
195
+ rescue StandardError
196
+ # skip failures
197
+ end
198
+
199
+ after = GC.stat[:total_allocated_objects]
200
+ GC.enable
201
+
202
+ $stdout.close
203
+ $stdout = real_stdout
204
+
205
+ total_allocs += (after - before)
206
+ scenario_count += 1
207
+ end
208
+
209
+ $stdout = real_stdout
210
+ results['integration_scenarios'] = scenario_count
211
+ results['integration_total_allocs'] = total_allocs
212
+ end
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Output — sorted table, diffable
216
+ # ---------------------------------------------------------------------------
217
+ puts format('%-40s | %s', 'metric', 'value')
218
+ puts '-' * 60
219
+ results.sort_by { |k, _| k }.each do |key, value|
220
+ puts format('%-40s | %s', key, value)
221
+ end
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Integration Memory Benchmark for Trakable
5
+ # Measures allocations across all integration scenarios
6
+ #
7
+ # Usage:
8
+ # ruby benchmark/integration_memory.rb
9
+
10
+ ENV['DISABLE_COVERAGE'] = '1'
11
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
12
+ require 'trakable'
13
+
14
+ scenarios_dir = File.expand_path('../integration/scenarios', __dir__)
15
+ scenario_dirs = Dir.glob("#{scenarios_dir}/*").select { |d| File.directory?(d) }.sort
16
+
17
+ results = []
18
+ real_stdout = $stdout
19
+
20
+ scenario_dirs.each do |dir|
21
+ scenario_file = File.join(dir, 'scenario.rb')
22
+ next unless File.exist?(scenario_file)
23
+
24
+ name = File.basename(dir)
25
+ Trakable::Context.reset!
26
+
27
+ # Suppress output
28
+ $stdout = File.open(File::NULL, 'w')
29
+
30
+ GC.start
31
+ GC.disable
32
+ before = GC.stat[:total_allocated_objects]
33
+
34
+ begin
35
+ load scenario_file
36
+ status = 'PASS'
37
+ rescue StandardError => e
38
+ status = "FAIL"
39
+ end
40
+
41
+ after = GC.stat[:total_allocated_objects]
42
+ GC.enable
43
+
44
+ $stdout.close
45
+ $stdout = real_stdout
46
+
47
+ results << { name: name, allocs: after - before, status: status }
48
+ end
49
+
50
+ $stdout = real_stdout
51
+
52
+ puts '=' * 70
53
+ puts 'Trakable Integration Memory Benchmark'
54
+ puts '=' * 70
55
+ puts
56
+
57
+ total = 0
58
+ results.each do |r|
59
+ total += r[:allocs]
60
+ flag = r[:status] == 'PASS' ? ' ' : 'F'
61
+ printf "[%s] %-45s %7d allocs\n", flag, r[:name][0..44], r[:allocs]
62
+ end
63
+
64
+ puts '-' * 70
65
+ printf " %-45s %7d allocs\n", "TOTAL (#{results.size} scenarios)", total
66
+ puts
67
+
68
+ gc = GC.stat
69
+ puts "GC runs: #{gc[:count]} | Live objects: #{gc[:heap_live_slots]}"
70
+ puts '=' * 70
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Memory & Allocation Benchmark for Trakable
5
+ # Measures object allocations and GC pressure for common operations
6
+ #
7
+ # Usage:
8
+ # ruby benchmark/memory_benchmark.rb
9
+
10
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
11
+ require 'trakable'
12
+
13
+ puts '=' * 60
14
+ puts 'Trakable Memory & Allocation Benchmark'
15
+ puts '=' * 60
16
+ puts
17
+
18
+ ITERATIONS = 10_000
19
+
20
+ # --- Helpers ---
21
+
22
+ def measure_allocs(n = ITERATIONS)
23
+ GC.start
24
+ GC.disable
25
+ before = GC.stat[:total_allocated_objects]
26
+ n.times { yield }
27
+ after = GC.stat[:total_allocated_objects]
28
+ GC.enable
29
+ (after - before).to_f / n
30
+ end
31
+
32
+ def measure_allocs_by_type(n = ITERATIONS)
33
+ GC.start
34
+ GC.disable
35
+ before = ObjectSpace.count_objects.dup
36
+ n.times { yield }
37
+ after = ObjectSpace.count_objects
38
+ GC.enable
39
+ result = {}
40
+ after.each do |type, count|
41
+ diff = count - (before[type] || 0)
42
+ result[type] = (diff.to_f / n).round(1) if diff > 0
43
+ end
44
+ result
45
+ end
46
+
47
+ def measure_memory_bytes(n = ITERATIONS)
48
+ GC.start
49
+ before = GC.stat[:malloc_increase_bytes]
50
+ n.times { yield }
51
+ after = GC.stat[:malloc_increase_bytes]
52
+ ((after - before).to_f / n).round(1)
53
+ end
54
+
55
+ # --- Mock ---
56
+
57
+ class MockRecord
58
+ attr_accessor :trakable_options, :previous_changes
59
+
60
+ def initialize
61
+ @trakable_options = { only: %w[title body], ignore: %w[status] }
62
+ @previous_changes = {
63
+ 'title' => %w[Old New], 'body' => %w[Old New],
64
+ 'views' => [0, 10], 'updated_at' => [Time.now - 3600, Time.now],
65
+ 'status' => %w[draft published]
66
+ }
67
+ end
68
+
69
+ def id
70
+ 1
71
+ end
72
+
73
+ def attributes
74
+ { 'id' => 1, 'title' => 'New', 'body' => 'New', 'views' => 10, 'status' => 'published' }
75
+ end
76
+ end
77
+
78
+ # --- Tests ---
79
+
80
+ record = MockRecord.new
81
+
82
+ # Warmup
83
+ 5.times { Trakable::Tracker.call(record, 'update') }
84
+
85
+ puts "1. Full Tracker.call (update with only/ignore)"
86
+ puts '-' * 50
87
+ allocs = measure_allocs { Trakable::Tracker.call(record, 'update') }
88
+ types = measure_allocs_by_type { Trakable::Tracker.call(record, 'update') }
89
+ puts " Allocations per call: #{allocs.round(1)}"
90
+ puts " Breakdown: #{types.select { |_, v| v > 0 }.map { |k, v| "#{k}=#{v}" }.join(', ')}"
91
+ puts
92
+
93
+ puts "2. Tracker.call (create, no changeset)"
94
+ puts '-' * 50
95
+ allocs = measure_allocs { Trakable::Tracker.call(record, 'create') }
96
+ types = measure_allocs_by_type { Trakable::Tracker.call(record, 'create') }
97
+ puts " Allocations per call: #{allocs.round(1)}"
98
+ puts " Breakdown: #{types.select { |_, v| v > 0 }.map { |k, v| "#{k}=#{v}" }.join(', ')}"
99
+ puts
100
+
101
+ puts "3. Tracker.call (destroy, full object state)"
102
+ puts '-' * 50
103
+ allocs = measure_allocs { Trakable::Tracker.call(record, 'destroy') }
104
+ types = measure_allocs_by_type { Trakable::Tracker.call(record, 'destroy') }
105
+ puts " Allocations per call: #{allocs.round(1)}"
106
+ puts " Breakdown: #{types.select { |_, v| v > 0 }.map { |k, v| "#{k}=#{v}" }.join(', ')}"
107
+ puts
108
+
109
+ puts "4. Trak.build only"
110
+ puts '-' * 50
111
+ allocs = measure_allocs { Trakable::Trak.build(item: record, event: 'update', changeset: {}, object: {}) }
112
+ types = measure_allocs_by_type { Trakable::Trak.build(item: record, event: 'update', changeset: {}, object: {}) }
113
+ puts " Allocations per call: #{allocs.round(1)}"
114
+ puts " Breakdown: #{types.select { |_, v| v > 0 }.map { |k, v| "#{k}=#{v}" }.join(', ')}"
115
+ puts
116
+
117
+ puts "5. filter_changeset only"
118
+ puts '-' * 50
119
+ tracker = Trakable::Tracker.new(record, 'update')
120
+ allocs = measure_allocs { tracker.send(:filter_changeset, record.previous_changes) }
121
+ puts " Allocations per call: #{allocs.round(1)}"
122
+ puts
123
+
124
+ puts "6. build_object_from_previous only"
125
+ puts '-' * 50
126
+ allocs = measure_allocs { tracker.send(:build_object_from_previous) }
127
+ puts " Allocations per call: #{allocs.round(1)}"
128
+ puts
129
+
130
+ # No-filter scenario
131
+ record_no_filter = MockRecord.new
132
+ record_no_filter.trakable_options = {}
133
+ puts "7. Tracker.call (update, no model filters)"
134
+ puts '-' * 50
135
+ allocs = measure_allocs { Trakable::Tracker.call(record_no_filter, 'update') }
136
+ puts " Allocations per call: #{allocs.round(1)}"
137
+ puts
138
+
139
+ puts '=' * 60
140
+ puts 'Memory Benchmark Complete'
141
+ puts '=' * 60
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Performance Benchmark for Trakable
5
+ # Measures tracking overhead for common operations
6
+
7
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
8
+ require 'trakable'
9
+ require 'benchmark'
10
+
11
+ puts "=" * 60
12
+ puts "Trakable Performance Benchmark"
13
+ puts "=" * 60
14
+ puts
15
+
16
+ # Configuration
17
+ ITERATIONS = 50_000
18
+
19
+ # Test 1: Filter changeset with only + ignore
20
+ puts "Test 1: filter_changeset with only/ignore (#{ITERATIONS} iterations)"
21
+ puts "-" * 50
22
+
23
+ # Simulate a record with trakable_options
24
+ class MockRecord
25
+ attr_accessor :trakable_options
26
+
27
+ def previous_changes
28
+ @previous_changes ||= {
29
+ 'title' => ['Old Title', 'New Title'],
30
+ 'body' => ['Old Body', 'New Body'],
31
+ 'views' => [0, 10],
32
+ 'updated_at' => [Time.now - 3600, Time.now],
33
+ 'status' => ['draft', 'published']
34
+ }
35
+ end
36
+ end
37
+
38
+ record = MockRecord.new
39
+ record.trakable_options = { only: %i[title body], ignore: %i[status] }
40
+
41
+ tracker = Trakable::Tracker.new(record, 'update')
42
+
43
+ time = Benchmark.realtime do
44
+ ITERATIONS.times { tracker.send(:filter_changeset, record.previous_changes.dup) }
45
+ end
46
+
47
+ puts "Time: #{(time * 1000).round(2)}ms"
48
+ puts "Per iteration: #{(time / ITERATIONS * 1_000_000).round(2)}µs"
49
+ baseline_filter = time
50
+ puts
51
+
52
+ # Test 2: Filter changeset without only/ignore (fast path)
53
+ puts "Test 2: filter_changeset without filters (#{ITERATIONS} iterations)"
54
+ puts "-" * 50
55
+
56
+ record2 = MockRecord.new
57
+ record2.trakable_options = {}
58
+ tracker2 = Trakable::Tracker.new(record2, 'update')
59
+
60
+ time = Benchmark.realtime do
61
+ ITERATIONS.times { tracker2.send(:filter_changeset, record2.previous_changes.dup) }
62
+ end
63
+
64
+ puts "Time: #{(time * 1000).round(2)}ms"
65
+ puts "Per iteration: #{(time / ITERATIONS * 1_000_000).round(2)}µs"
66
+ puts
67
+
68
+ # Test 3: String conversion overhead
69
+ puts "Test 3: Array().map(&:to_s) overhead (#{ITERATIONS} iterations)"
70
+ puts "-" * 50
71
+
72
+ only_symbols = %i[title body views status]
73
+ only_strings = %w[title body views status]
74
+
75
+ time_symbols = Benchmark.realtime do
76
+ ITERATIONS.times { Array(only_symbols).map(&:to_s) }
77
+ end
78
+
79
+ time_strings = Benchmark.realtime do
80
+ ITERATIONS.times { Array(only_strings).map(&:to_s) }
81
+ end
82
+
83
+ puts "Symbols: #{(time_symbols * 1000).round(2)}ms"
84
+ puts "Pre-converted strings: #{(time_strings * 1000).round(2)}ms"
85
+ puts "Speedup: #{(time_symbols / time_strings).round(2)}x"
86
+ puts
87
+
88
+ # Test 4: Set vs Array for ignore lookups
89
+ puts "Test 4: Set vs Array for ignore lookups (#{ITERATIONS} iterations)"
90
+ puts "-" * 50
91
+
92
+ ignore_array = %w[updated_at created_at id views status]
93
+ ignore_set = ignore_array.to_set
94
+ keys = %w[title body views status updated_at created_at id]
95
+
96
+ time_array = Benchmark.realtime do
97
+ ITERATIONS.times { keys.reject { |k| ignore_array.include?(k) } }
98
+ end
99
+
100
+ time_set = Benchmark.realtime do
101
+ ITERATIONS.times { keys.reject { |k| ignore_set.include?(k) } }
102
+ end
103
+
104
+ puts "Array#include?: #{(time_array * 1000).round(2)}ms"
105
+ puts "Set#include?: #{(time_set * 1000).round(2)}ms"
106
+ puts "Speedup: #{(time_array / time_set).round(2)}x"
107
+ puts
108
+
109
+ # Test 5: respond_to? vs direct check
110
+ puts "Test 5: respond_to? overhead (#{ITERATIONS} iterations)"
111
+ puts "-" * 50
112
+
113
+ obj = Object.new
114
+
115
+ time_respond = Benchmark.realtime do
116
+ ITERATIONS.times { obj.respond_to?(:trakable_options) }
117
+ end
118
+
119
+ time_direct = Benchmark.realtime do
120
+ ITERATIONS.times { obj.is_a?(MockRecord) }
121
+ end
122
+
123
+ puts "respond_to?: #{(time_respond * 1000).round(2)}ms"
124
+ puts "is_a?: #{(time_direct * 1000).round(2)}ms"
125
+ puts
126
+
127
+ puts "=" * 60
128
+ puts "Benchmark Complete"
129
+ puts "Baseline filter_changeset: #{(baseline_filter / ITERATIONS * 1_000_000).round(2)}µs per call"
130
+ puts "=" * 60
@@ -0,0 +1,65 @@
1
+ # Trakable Integration Tests
2
+
3
+ ## Overview
4
+
5
+ Integration tests simulate real-world usage scenarios of the Trakable gem. Each scenario tests a complete feature or workflow from start to finish.
6
+
7
+ ## Running Tests
8
+
9
+ ```bash
10
+ # Run all integration scenarios
11
+ ruby integration/run_all.rb
12
+
13
+ # Run a specific scenario
14
+ ruby integration/scenarios/01-basic-tracking/scenario.rb
15
+ ```
16
+
17
+ ## Scenarios
18
+
19
+ | # | Scenario | Description |
20
+ |---|----------|-------------|
21
+ | 01 | Basic Tracking | Tests create/update/destroy tracking |
22
+ | 02 | Revert & Restoration | Tests revert! and reify functionality |
23
+ | 03 | Whodunnit Tracking | Tests polymorphic whodunnit and context |
24
+ | 04 | Cleanup & Retention | Tests max_traks and retention policies |
25
+ | 05 | Without Tracking | Tests skipping tracking |
26
+
27
+ ## Creating a New Scenario
28
+
29
+ 1. Create a new directory: `integration/scenarios/NN-description/`
30
+ 2. Create `scenario.rb` with your test code
31
+ 3. Include the scenario runner:
32
+
33
+ ```ruby
34
+ # frozen_string_literal: true
35
+
36
+ require_relative '../scenario_runner'
37
+
38
+ run_scenario 'My Scenario Name' do
39
+ puts '=== Scenario NN: My Scenario Name ==='
40
+
41
+ # Your test code here
42
+
43
+ puts '=== Scenario NN PASSED ✓ ==='
44
+ end
45
+ ```
46
+
47
+ ## Scenario Structure
48
+
49
+ Each scenario should:
50
+ - Be self-contained and runnable independently
51
+ - Clean up after itself (reset context, etc.)
52
+ - Print clear progress messages
53
+ - Use assertions to verify behavior
54
+ - Handle exceptions gracefully
55
+
56
+ ## Available Assertions
57
+
58
+ ```ruby
59
+ assert(condition, message)
60
+ assert_equal(expected, actual)
61
+ assert_kind_of(Class, object)
62
+ assert_includes(collection, item)
63
+ refute(condition, message)
64
+ refute_nil(object)
65
+ ```
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Trakable Integration Test Runner
5
+ # Runs all scenario tests in order
6
+
7
+ puts '=' * 70
8
+ puts 'TRAKABLE INTEGRATION TESTS'
9
+ puts '=' * 70
10
+ puts
11
+
12
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
13
+ require 'trakable'
14
+ require 'minitest/autorun'
15
+
16
+ # Reset context before tests
17
+ Trakable::Context.reset!
18
+
19
+ # Track results
20
+ results = { passed: 0, failed: 0, errors: [] }
21
+
22
+ # Run each scenario
23
+ scenarios_dir = File.expand_path('scenarios', __dir__)
24
+ scenario_dirs = Dir.glob("#{scenarios_dir}/*").select { |d| File.directory?(d) }.sort
25
+
26
+ scenario_dirs.each do |dir|
27
+ scenario_file = File.join(dir, 'scenario.rb')
28
+ next unless File.exist?(scenario_file)
29
+
30
+ puts "\n#{'=' * 70}"
31
+ puts "Running: #{File.basename(dir)}"
32
+ puts '=' * 70
33
+
34
+ begin
35
+ load scenario_file
36
+ results[:passed] += 1
37
+ rescue StandardError => e
38
+ results[:failed] += 1
39
+ results[:errors] << { scenario: File.basename(dir), error: e.message }
40
+ end
41
+
42
+ # Reset context between scenarios
43
+ Trakable::Context.reset!
44
+ end
45
+
46
+ # Summary
47
+ puts "\n#{'=' * 70}"
48
+ puts 'INTEGRATION TEST SUMMARY'
49
+ puts '=' * 70
50
+ puts "Passed: #{results[:passed]}"
51
+ puts "Failed: #{results[:failed]}"
52
+
53
+ if results[:errors].any?
54
+ puts "\nFailures:"
55
+ results[:errors].each do |error|
56
+ puts " - #{error[:scenario]}: #{error[:error]}"
57
+ end
58
+ exit 1
59
+ else
60
+ puts "\nAll integration tests passed! ✓"
61
+ exit 0
62
+ end