namo.rb 0.31.7

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.
@@ -0,0 +1,487 @@
1
+ require 'date'
2
+ require 'minitest/autorun'
3
+ require 'minitest-spec-context'
4
+
5
+ require_relative '../../lib/namo'
6
+
7
+ describe Namo::Row do
8
+ let(:row_data) do
9
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100}
10
+ end
11
+
12
+ let(:formulae) do
13
+ Namo::Formulae.new
14
+ end
15
+
16
+ let(:row) do
17
+ Namo::Row.new(row_data, formulae)
18
+ end
19
+
20
+ describe "#[]" do
21
+ it "returns raw data by dimension name" do
22
+ _(row[:product]).must_equal 'Widget'
23
+ _(row[:price]).must_equal 10.0
24
+ end
25
+
26
+ it "returns nil for missing dimensions" do
27
+ _(row[:missing]).must_be_nil
28
+ end
29
+
30
+ it "resolves formulae over raw data" do
31
+ formulae[:revenue] = proc{|r| r[:price] * r[:quantity]}
32
+ _(row[:revenue]).must_equal 1000.0
33
+ end
34
+
35
+ it "composes formulae" do
36
+ formulae[:revenue] = proc{|r| r[:price] * r[:quantity]}
37
+ formulae[:cost] = proc{|r| r[:quantity] * 4.0}
38
+ formulae[:profit] = proc{|r| r[:revenue] - r[:cost]}
39
+ _(row[:profit]).must_equal 600.0
40
+ end
41
+ end
42
+
43
+ describe "constructor" do
44
+ it "constructs from the two-argument form (namo defaults nil)" do
45
+ _(Namo::Row.new(row_data, formulae)).must_be_kind_of Namo::Row
46
+ end
47
+
48
+ it "accepts a third namo argument" do
49
+ namo = Namo.new([row_data])
50
+ _(Namo::Row.new(row_data, formulae, namo)).must_be_kind_of Namo::Row
51
+ end
52
+ end
53
+
54
+ describe "#[] arity dispatch" do
55
+ it "calls an arity-1 formula with the Row only" do
56
+ seen = nil
57
+ formulae[:dim] = ->(r){seen = r; 1}
58
+ row[:dim]
59
+ _(seen).must_be_same_as row
60
+ end
61
+
62
+ it "calls an arity-2 formula with the Row and the yielding Namo" do
63
+ namo = Namo.new([row_data])
64
+ row = Namo::Row.new(row_data, formulae, namo)
65
+ seen_row = nil
66
+ seen_namo = nil
67
+ formulae[:dim] = ->(r, n){seen_row = r; seen_namo = n; 1}
68
+ row[:dim]
69
+ _(seen_row).must_be_same_as row
70
+ _(seen_namo.equal?(namo)).must_equal true
71
+ end
72
+
73
+ it "takes the one-arity path for an arity-0 proc" do
74
+ formulae[:dim] = proc{42}
75
+ _(row[:dim]).must_equal 42
76
+ end
77
+
78
+ it "takes the one-arity path for a negative-arity proc" do
79
+ seen_rest = nil
80
+ formulae[:dim] = proc{|r, *rest| seen_rest = rest; 1}
81
+ row[:dim]
82
+ _(seen_rest).must_equal []
83
+ end
84
+
85
+ it "raises ArgumentError naming the formula when an arity-2 formula has no Namo context" do
86
+ formulae[:sma] = ->(r, n){n.count}
87
+ error = _(proc{row[:sma]}).must_raise ArgumentError
88
+ _(error.message).must_match(/sma/)
89
+ end
90
+
91
+ it "resolves an arity-1 formula on a Row with no Namo context" do
92
+ formulae[:revenue] = ->(r){r[:price] * r[:quantity]}
93
+ _(row[:revenue]).must_equal 1000.0
94
+ end
95
+ end
96
+
97
+ describe "#[] parameterised formulae" do
98
+ let(:namo) do
99
+ Namo.new([row_data])
100
+ end
101
+
102
+ let(:contextual_row) do
103
+ Namo::Row.new(row_data, formulae, namo)
104
+ end
105
+
106
+ it "calls an arity-3 formula with the Row, the yielding Namo, and one argument" do
107
+ seen = nil
108
+ formulae[:scaled] = ->(r, n, factor){seen = [r, n, factor]; r[:price] * factor}
109
+ _(contextual_row[:scaled, 3]).must_equal 30.0
110
+ _(seen[0]).must_be_same_as contextual_row
111
+ _(seen[1].equal?(namo)).must_equal true
112
+ _(seen[2]).must_equal 3
113
+ end
114
+
115
+ it "calls an arity-4 formula with two arguments" do
116
+ formulae[:metric] = ->(r, n, field, factor){r[field] * factor}
117
+ _(contextual_row[:metric, :quantity, 2]).must_equal 200
118
+ end
119
+
120
+ it "forwards a trailing splat's arguments past a required one (arity -4)" do
121
+ formulae[:dim] = proc{|r, n, field, *rest| [field, rest]}
122
+ _(contextual_row[:dim, :price]).must_equal [:price, []]
123
+ _(contextual_row[:dim, :price, 1, 2]).must_equal [:price, [1, 2]]
124
+ end
125
+
126
+ it "treats a splat directly after namo as collection-scoped taking any number of arguments (arity -3)" do
127
+ formulae[:dim] = proc{|r, n, *rest| rest}
128
+ _(contextual_row[:dim]).must_equal []
129
+ _(contextual_row[:dim, 1, 2, 3]).must_equal [1, 2, 3]
130
+ end
131
+
132
+ it "keeps a one-required-parameter proc row-scoped regardless of trailing optionals" do
133
+ seen = :unset
134
+ formulae[:dim] = ->(r, n = :fallback){seen = n; 1}
135
+ contextual_row[:dim]
136
+ _(seen).must_equal :fallback
137
+ end
138
+
139
+ it "lets a row-scoped formula call a parameterised formula with arguments" do
140
+ formulae[:metric] = ->(r, n, field, factor){r[field] * factor}
141
+ formulae[:double_quantity] = ->(r){r[:metric, :quantity, 2]}
142
+ _(contextual_row[:double_quantity]).must_equal 200
143
+ end
144
+
145
+ it "raises ArgumentError naming the formula when a parameterised formula has no Namo context" do
146
+ formulae[:metric] = ->(r, n, field){r[field]}
147
+ error = _(proc{row[:metric, :price]}).must_raise ArgumentError
148
+ _(error.message).must_match(/metric/)
149
+ end
150
+ end
151
+
152
+ describe "#[] argument-count enforcement" do
153
+ let(:namo) do
154
+ Namo.new([row_data])
155
+ end
156
+
157
+ let(:contextual_row) do
158
+ Namo::Row.new(row_data, formulae, namo)
159
+ end
160
+
161
+ it "raises when a parameterised formula is given too few arguments" do
162
+ formulae[:metric] = ->(r, n, field, period){r[field] * period}
163
+ error = _(proc{contextual_row[:metric, :price]}).must_raise ArgumentError
164
+ _(error.message).must_equal "wrong number of arguments for :metric (given 1, expected 2)"
165
+ end
166
+
167
+ it "raises when a fixed-arity parameterised formula is given too many arguments" do
168
+ formulae[:metric] = ->(r, n, field){r[field]}
169
+ error = _(proc{contextual_row[:metric, :price, 20]}).must_raise ArgumentError
170
+ _(error.message).must_equal "wrong number of arguments for :metric (given 2, expected 1)"
171
+ end
172
+
173
+ it "raises when a splatted parameterised formula is given fewer than its required arguments" do
174
+ formulae[:metric] = proc{|r, n, field, *rest| r[field]}
175
+ error = _(proc{contextual_row[:metric]}).must_raise ArgumentError
176
+ _(error.message).must_equal "wrong number of arguments for :metric (given 0, expected 1+)"
177
+ end
178
+
179
+ it "raises when arguments are given for a data dimension" do
180
+ error = _(proc{row[:price, 20]}).must_raise ArgumentError
181
+ _(error.message).must_equal "wrong number of arguments for :price (given 1, expected 0)"
182
+ end
183
+
184
+ it "raises when arguments are given for a row-scoped formula" do
185
+ formulae[:revenue] = proc{|r| r[:price] * r[:quantity]}
186
+ error = _(proc{row[:revenue, 20]}).must_raise ArgumentError
187
+ _(error.message).must_equal "wrong number of arguments for :revenue (given 1, expected 0)"
188
+ end
189
+
190
+ it "raises when arguments are given for a two-arity formula" do
191
+ formulae[:row_count] = ->(r, n){n.count}
192
+ error = _(proc{contextual_row[:row_count, 1]}).must_raise ArgumentError
193
+ _(error.message).must_equal "wrong number of arguments for :row_count (given 1, expected 0)"
194
+ end
195
+
196
+ it "raises when arguments are given for a missing dimension" do
197
+ error = _(proc{row[:missing, 1]}).must_raise ArgumentError
198
+ _(error.message).must_equal "wrong number of arguments for :missing (given 1, expected 0)"
199
+ end
200
+ end
201
+
202
+ describe "#match?" do
203
+ it "matches a single value" do
204
+ _(row.match?(product: 'Widget')).must_equal true
205
+ _(row.match?(product: 'Gadget')).must_equal false
206
+ end
207
+
208
+ it "matches an array of values" do
209
+ _(row.match?(product: ['Widget', 'Gadget'])).must_equal true
210
+ _(row.match?(product: ['Gadget'])).must_equal false
211
+ end
212
+
213
+ it "matches a range" do
214
+ _(row.match?(price: 5.0..15.0)).must_equal true
215
+ _(row.match?(price: 20.0..30.0)).must_equal false
216
+ end
217
+
218
+ it "matches multiple dimensions" do
219
+ _(row.match?(product: 'Widget', quarter: 'Q1')).must_equal true
220
+ _(row.match?(product: 'Widget', quarter: 'Q2')).must_equal false
221
+ end
222
+
223
+ it "resolves a two-arity derived dimension when the Row carries a Namo" do
224
+ namo = Namo.new([row_data])
225
+ formulae[:row_count] = ->(r, n){n.count}
226
+ row = Namo::Row.new(row_data, formulae, namo)
227
+ _(row.match?(row_count: 1)).must_equal true
228
+ _(row.match?(row_count: 2)).must_equal false
229
+ end
230
+
231
+ describe "Proc predicates" do
232
+ it "matches when the proc returns true" do
233
+ _(row.match?(price: ->(v){v < 15.0})).must_equal true
234
+ end
235
+
236
+ it "doesn't match when the proc returns false" do
237
+ _(row.match?(price: ->(v){v > 100.0})).must_equal false
238
+ end
239
+
240
+ it "doesn't match when the proc returns nil" do
241
+ _(row.match?(price: ->(v){nil})).must_equal false
242
+ end
243
+
244
+ it "matches when the proc returns a truthy non-boolean" do
245
+ _(row.match?(price: ->(v){"truthy"})).must_equal true
246
+ end
247
+
248
+ it "passes nil to the proc when the dimension is missing" do
249
+ seen = nil
250
+ row.match?(missing: ->(v){seen = v; true})
251
+ _(seen).must_be_nil
252
+ end
253
+
254
+ it "lets the proc decide what to do with a nil value" do
255
+ _(row.match?(missing: ->(v){v.nil?})).must_equal true
256
+ _(row.match?(missing: ->(v){!v.nil?})).must_equal false
257
+ end
258
+
259
+ it "composes with an exact value on another dimension" do
260
+ _(row.match?(price: ->(v){v < 15.0}, product: 'Widget')).must_equal true
261
+ _(row.match?(price: ->(v){v < 15.0}, product: 'Gadget')).must_equal false
262
+ end
263
+
264
+ it "composes with an array on another dimension" do
265
+ _(row.match?(price: ->(v){v < 15.0}, product: ['Widget', 'Gadget'])).must_equal true
266
+ _(row.match?(price: ->(v){v < 15.0}, product: ['Gadget'])).must_equal false
267
+ end
268
+
269
+ it "composes with a range on another dimension" do
270
+ _(row.match?(price: ->(v){v < 15.0}, quantity: 50..150)).must_equal true
271
+ _(row.match?(price: ->(v){v < 15.0}, quantity: 200..300)).must_equal false
272
+ end
273
+
274
+ it "composes with a regex on another dimension" do
275
+ _(row.match?(price: ->(v){v < 15.0}, product: /^W/)).must_equal true
276
+ _(row.match?(price: ->(v){v < 15.0}, product: /^G/)).must_equal false
277
+ end
278
+
279
+ it "composes multiple proc predicates across dimensions" do
280
+ _(row.match?(
281
+ price: ->(v){v < 15.0},
282
+ quantity: ->(v){v >= 100}
283
+ )).must_equal true
284
+ _(row.match?(
285
+ price: ->(v){v < 15.0},
286
+ quantity: ->(v){v >= 200}
287
+ )).must_equal false
288
+ end
289
+
290
+ it "carries through to a formula-defined dimension" do
291
+ formulae[:revenue] = proc{|r| r[:price] * r[:quantity]}
292
+ _(row.match?(revenue: ->(v){v == 1000.0})).must_equal true
293
+ _(row.match?(revenue: ->(v){v > 5000.0})).must_equal false
294
+ end
295
+ end
296
+
297
+ describe "Regexp predicates" do
298
+ it "matches against a String value" do
299
+ _(row.match?(product: /Widget/)).must_equal true
300
+ end
301
+
302
+ it "doesn't match when the regex doesn't apply" do
303
+ _(row.match?(product: /Gadget/)).must_equal false
304
+ end
305
+
306
+ it "supports case-insensitive matching" do
307
+ _(row.match?(product: /widget/i)).must_equal true
308
+ _(row.match?(product: /widget/)).must_equal false
309
+ end
310
+
311
+ it "supports anchored patterns" do
312
+ _(row.match?(product: /^Wid/)).must_equal true
313
+ _(row.match?(product: /^Gad/)).must_equal false
314
+ end
315
+
316
+ it "coerces Integer values via to_s" do
317
+ _(row.match?(quantity: /100/)).must_equal true
318
+ _(row.match?(quantity: /^1/)).must_equal true
319
+ _(row.match?(quantity: /^9/)).must_equal false
320
+ end
321
+
322
+ it "coerces Float values via to_s" do
323
+ _(row.match?(price: /^10\./)).must_equal true
324
+ _(row.match?(price: /\.0$/)).must_equal true
325
+ _(row.match?(price: /^99/)).must_equal false
326
+ end
327
+
328
+ it "coerces Date values via to_s" do
329
+ row_data[:date] = Date.new(2026, 5, 21)
330
+ _(row.match?(date: /^2026/)).must_equal true
331
+ _(row.match?(date: /-05-/)).must_equal true
332
+ _(row.match?(date: /^2025/)).must_equal false
333
+ end
334
+
335
+ it "coerces Symbol values via to_s" do
336
+ row_data[:tag] = :priority
337
+ _(row.match?(tag: /priority/)).must_equal true
338
+ _(row.match?(tag: /^pri/)).must_equal true
339
+ _(row.match?(tag: /xyz/)).must_equal false
340
+ end
341
+
342
+ it "coerces nil to an empty string" do
343
+ _(row.match?(missing: //)).must_equal true
344
+ _(row.match?(missing: /./)).must_equal false
345
+ end
346
+
347
+ it "composes with an exact value on another dimension" do
348
+ _(row.match?(product: /^W/, quarter: 'Q1')).must_equal true
349
+ _(row.match?(product: /^W/, quarter: 'Q2')).must_equal false
350
+ end
351
+
352
+ it "composes with an array on another dimension" do
353
+ _(row.match?(product: /^W/, quarter: ['Q1', 'Q2'])).must_equal true
354
+ _(row.match?(product: /^W/, quarter: ['Q3'])).must_equal false
355
+ end
356
+
357
+ it "composes with a range on another dimension" do
358
+ _(row.match?(product: /^W/, price: 5.0..15.0)).must_equal true
359
+ _(row.match?(product: /^W/, price: 20.0..30.0)).must_equal false
360
+ end
361
+
362
+ it "composes with a proc on another dimension" do
363
+ _(row.match?(product: /^W/, quantity: ->(v){v >= 100})).must_equal true
364
+ _(row.match?(product: /^W/, quantity: ->(v){v >= 200})).must_equal false
365
+ end
366
+
367
+ it "composes multiple regex predicates across dimensions" do
368
+ _(row.match?(product: /^W/, quarter: /^Q/)).must_equal true
369
+ _(row.match?(product: /^W/, quarter: /^X/)).must_equal false
370
+ end
371
+
372
+ it "carries through to a formula-defined dimension" do
373
+ formulae[:label] = proc{|r| "#{r[:product]}-#{r[:quarter]}"}
374
+ _(row.match?(label: /Widget-Q1/)).must_equal true
375
+ _(row.match?(label: /Gadget/)).must_equal false
376
+ end
377
+ end
378
+ end
379
+
380
+ describe "#to_h" do
381
+ it "returns the underlying row hash" do
382
+ _(row.to_h).must_equal row_data
383
+ end
384
+ end
385
+
386
+ describe "#==" do
387
+ it "is true for two Rows with equal @row" do
388
+ a = Namo::Row.new({product: 'Widget', price: 10.0}, {})
389
+ b = Namo::Row.new({product: 'Widget', price: 10.0}, {})
390
+ _(a == b).must_equal true
391
+ end
392
+
393
+ it "is false for two Rows with different @row" do
394
+ a = Namo::Row.new({product: 'Widget', price: 10.0}, {})
395
+ b = Namo::Row.new({product: 'Gadget', price: 10.0}, {})
396
+ _(a == b).must_equal false
397
+ end
398
+
399
+ it "is false for a non-Row operand" do
400
+ a = Namo::Row.new({product: 'Widget'}, {})
401
+ _(a == {product: 'Widget'}).must_equal false
402
+ _(a == 'Widget').must_equal false
403
+ _(a == nil).must_equal false
404
+ end
405
+
406
+ it "ignores formulae" do
407
+ a = Namo::Row.new({price: 10.0, quantity: 100}, {})
408
+ b = Namo::Row.new({price: 10.0, quantity: 100}, {revenue: proc{|r| r[:price] * r[:quantity]}})
409
+ _(a == b).must_equal true
410
+ end
411
+ end
412
+
413
+ describe "#eql?" do
414
+ it "is true for two Rows with eql? @row" do
415
+ a = Namo::Row.new({product: 'Widget', price: 10.0}, {})
416
+ b = Namo::Row.new({product: 'Widget', price: 10.0}, {})
417
+ _(a.eql?(b)).must_equal true
418
+ end
419
+
420
+ it "is false for a non-Row operand" do
421
+ a = Namo::Row.new({product: 'Widget'}, {})
422
+ _(a.eql?({product: 'Widget'})).must_equal false
423
+ _(a.eql?(nil)).must_equal false
424
+ end
425
+
426
+ it "distinguishes numeric types the way Hash#eql? does" do
427
+ a = Namo::Row.new({n: 1}, {})
428
+ b = Namo::Row.new({n: 1.0}, {})
429
+ _(a == b).must_equal true
430
+ _(a.eql?(b)).must_equal false
431
+ end
432
+
433
+ it "ignores formulae" do
434
+ a = Namo::Row.new({price: 10.0, quantity: 100}, {})
435
+ b = Namo::Row.new({price: 10.0, quantity: 100}, {revenue: proc{|r| r[:price] * r[:quantity]}})
436
+ _(a.eql?(b)).must_equal true
437
+ end
438
+ end
439
+
440
+ describe "#hash" do
441
+ it "is equal for two Rows that are eql?" do
442
+ a = Namo::Row.new({product: 'Widget', price: 10.0}, {})
443
+ b = Namo::Row.new({product: 'Widget', price: 10.0}, {})
444
+ _(a.hash).must_equal b.hash
445
+ end
446
+
447
+ it "lets Rows work as Hash keys" do
448
+ a = Namo::Row.new({product: 'Widget'}, {})
449
+ b = Namo::Row.new({product: 'Gadget'}, {})
450
+ lookup = Namo::Row.new({product: 'Widget'}, {})
451
+ h = {a => :x, b => :y}
452
+ _(h[lookup]).must_equal :x
453
+ end
454
+
455
+ it "lets Array#uniq dedupe equal Rows" do
456
+ a = Namo::Row.new({product: 'Widget'}, {})
457
+ b = Namo::Row.new({product: 'Gadget'}, {})
458
+ duplicate_of_a = Namo::Row.new({product: 'Widget'}, {})
459
+ _([a, b, duplicate_of_a].uniq.length).must_equal 2
460
+ end
461
+ end
462
+
463
+ describe "#inspect" do
464
+ it "renders the row data" do
465
+ _(Namo::Row.new({a: 1}, Namo::Formulae.new).inspect).must_equal "#<Namo::Row {a: 1}>"
466
+ end
467
+
468
+ it "shows a derived dimension with its value, in among the stored ones" do
469
+ formulae = Namo::Formulae.new
470
+ formulae[:b] = proc{|row| row[:a] + 1}
471
+ _(Namo::Row.new({a: 1}, formulae).inspect).must_equal "#<Namo::Row {a: 1, b: 2} derived: [:b]>"
472
+ end
473
+
474
+ it "names a derived dimension whose formula raises, without a value" do
475
+ formulae = Namo::Formulae.new
476
+ formulae[:b] = proc{|row| raise 'no value to show'}
477
+ _(Namo::Row.new({a: 1}, formulae).inspect).must_equal "#<Namo::Row {a: 1} derived: [:b]>"
478
+ end
479
+
480
+ it "does not render the Namo the row came from" do
481
+ namo = Namo.new((1..1000).map{|i| {a: i}})
482
+ _(namo.first.inspect).wont_match(/\{a: 2\}/)
483
+ _(namo.first.inspect.length).must_be :<, 100
484
+ end
485
+ end
486
+
487
+ end
@@ -0,0 +1,16 @@
1
+ require 'minitest/autorun'
2
+ require 'minitest-spec-context'
3
+
4
+ require_relative '../lib/namo'
5
+
6
+ describe Symbol do
7
+ describe "#-@" do
8
+ it "returns a NegatedDimension" do
9
+ _(-:price).must_be_kind_of Namo::NegatedDimension
10
+ end
11
+
12
+ it "preserves the symbol name" do
13
+ _((-:price).name).must_equal :price
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,44 @@
1
+ # test/console_test.rb
2
+
3
+ require 'minitest/autorun'
4
+ require 'minitest-spec-context'
5
+
6
+ # bin/console is the conventional gem prompt: irb with the library loaded and
7
+ # nothing else. It held the demo's fixtures until 20260826, so that a question
8
+ # asked here was asked of the data the demo had shown — a guarantee the demo's
9
+ # own `i` now keeps better, opening irb on the binding the run is using rather
10
+ # than on a second process holding equal values.
11
+ #
12
+ # What is left to assert is that it starts, that Namo is there, and that it needs
13
+ # nothing off the load path.
14
+
15
+ describe 'bin/console' do
16
+ def console(*expressions)
17
+ root = File.expand_path('..', __dir__)
18
+ IO.popen([File.join(root, 'bin', 'console'), '--prompt', 'simple'],
19
+ 'r+', chdir: root, err: [:child, :out]) do |io|
20
+ io.puts(expressions, 'exit')
21
+ io.close_write
22
+ io.read
23
+ end
24
+ end
25
+
26
+ it "starts with Namo loaded" do
27
+ _(console('Namo.new([{a: 1}]).dimensions.inspect')).must_match(/\[:a\]/)
28
+ end
29
+
30
+ it "starts without echoing its own source" do
31
+ _(console('1')).wont_match(/IRB\.start/)
32
+ end
33
+
34
+ it "needs nothing but the gem's own dependencies" do
35
+ root = File.expand_path('..', __dir__)
36
+ output = IO.popen({'RUBYLIB' => nil}, [File.join(root, 'bin', 'console'), '--prompt', 'simple'],
37
+ 'r+', chdir: root, err: [:child, :out]) do |io|
38
+ io.puts('exit')
39
+ io.close_write
40
+ io.read
41
+ end
42
+ _(output).wont_match(/LoadError/)
43
+ end
44
+ end
data/test/demo_test.rb ADDED
@@ -0,0 +1,86 @@
1
+ # test/demo_test.rb
2
+
3
+ require 'minitest/autorun'
4
+ require 'minitest-spec-context'
5
+
6
+ # script/demo is 300-odd lines exercising selection, projection, formulae,
7
+ # group_by, summary, the operators and inspect, and nothing else runs it. These
8
+ # assert only that each section completes, which is the difference between a
9
+ # demonstration which rots quietly and one which fails the suite when the library
10
+ # moves under it.
11
+ #
12
+ # A process apiece, since the script is a program rather than a library and its
13
+ # sections share a binding within a run.
14
+
15
+ describe 'script/demo' do
16
+ def demo(*sections)
17
+ root = File.expand_path('..', __dir__)
18
+ IO.popen([File.join(root, 'script', 'demo'), *sections],
19
+ chdir: root, err: [:child, :out]){|io| io.read}
20
+ end
21
+
22
+ def sections
23
+ @sections ||= demo('--help').scan(/^ [* ] ([a-z_]+)$/).flatten
24
+ end
25
+
26
+ def cut
27
+ @cut ||= demo('--help').scan(/^ \* ([a-z_]+)$/).flatten
28
+ end
29
+
30
+ it "lists its sections" do
31
+ _(sections).wont_be_empty
32
+ _(sections).must_include 'ingestion'
33
+ end
34
+
35
+ it "runs every section without raising" do
36
+ failed = sections.reject do |section|
37
+ demo(section)
38
+ $?.success?
39
+ end
40
+ _(failed).must_be_empty
41
+ end
42
+
43
+ it "has a talk cut, and it is a subset of the sections" do
44
+ _(cut).wont_be_empty
45
+ _(cut - sections).must_be_empty
46
+ end
47
+
48
+ it "runs the talk cut" do
49
+ demo('talk')
50
+ _($?.success?).must_equal true
51
+ end
52
+
53
+ # The padding is what holds a section title in one place on the screen, and it
54
+ # can only pad down to the constants. A section grown past them would silently
55
+ # start pushing the next one about. Rows rather than lines, since a line wider
56
+ # than the window costs more than one of them.
57
+ #
58
+ # The cut rather than every section: those are the ones which have to fit on the
59
+ # day, and the rest are reference, free to run long and be scrolled.
60
+ it "has no section in the talk cut taller than the slide height" do
61
+ source = File.read(File.join(File.expand_path('..', __dir__), 'script', 'demo'))
62
+ height, width = %w[SLIDE_HEIGHT SLIDE_WIDTH].map{|name| source[/^#{name} = (\d+)$/, 1].to_i}
63
+ _([height, width].min).must_be :>, 0
64
+ overlong = cut.select do |section|
65
+ demo(section).lines.sum{|line| [(line.chomp.size / width.to_f).ceil, 1].max} > height
66
+ end
67
+ _(overlong).must_be_empty
68
+ end
69
+
70
+ it "runs the whole script" do
71
+ demo
72
+ _($?.success?).must_equal true
73
+ end
74
+
75
+ it "refuses a section it does not have" do
76
+ demo('nonexistent')
77
+ _($?.success?).must_equal false
78
+ end
79
+
80
+ it "needs nothing but the gem's own dependencies" do
81
+ root = File.expand_path('..', __dir__)
82
+ output = IO.popen({'RUBYLIB' => nil}, [File.join(root, 'script', 'demo'), 'ingestion'],
83
+ chdir: root, err: [:child, :out]){|io| io.read}
84
+ _(output).wont_match(/LoadError/)
85
+ end
86
+ end