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.
data/test/namo_test.rb ADDED
@@ -0,0 +1,3328 @@
1
+ require 'minitest/autorun'
2
+ require 'minitest-spec-context'
3
+
4
+ require_relative '../lib/namo'
5
+
6
+ module SupportedFlow
7
+ include Namo::Formulary
8
+ def net_volume(row); gross(row) - row[:sells] * 2; end
9
+ private
10
+ def gross(row); row[:buys] + row[:sells]; end
11
+ end
12
+
13
+ describe Namo do
14
+ let(:sample_data) do
15
+ [
16
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
17
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
18
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
19
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
20
+ ]
21
+ end
22
+
23
+ let(:sales) do
24
+ Namo.new(sample_data)
25
+ end
26
+
27
+ describe "empty Namo" do
28
+ it "has empty dimensions" do
29
+ _(Namo.new.dimensions).must_equal []
30
+ end
31
+
32
+ it "has empty data_dimensions" do
33
+ _(Namo.new.data_dimensions).must_equal []
34
+ end
35
+
36
+ it "exposes formulae even with no data" do
37
+ namo = Namo.new
38
+ namo[:x] = proc{|r| 42}
39
+ _(namo.dimensions).must_equal [:x]
40
+ end
41
+ end
42
+
43
+ describe "construction" do
44
+ it "accepts positional data" do
45
+ _(Namo.new([{x: 1}]).data).must_equal [{x: 1}]
46
+ end
47
+
48
+ it "accepts positional data with keyword formulae" do
49
+ namo = Namo.new([{x: 1}], formulae: {y: proc{|r| r[:x] * 2}})
50
+ _(namo.data).must_equal [{x: 1}]
51
+ _(namo.values(:y)).must_equal [2]
52
+ end
53
+
54
+ it "produces an empty Namo with no arguments" do
55
+ namo = Namo.new
56
+ _(namo.data).must_equal []
57
+ _(namo.formulae).must_be_empty
58
+ end
59
+
60
+ it "accepts keyword formulae with no data" do
61
+ namo = Namo.new(formulae: {y: proc{|r| r[:x] * 2}})
62
+ _(namo.data).must_equal []
63
+ _(namo.derived_dimensions).must_equal [:y]
64
+ end
65
+
66
+ it "wraps keyword formulae given as a Hash in a Formulae" do
67
+ namo = Namo.new(formulae: {y: proc{|r| r[:x] * 2}})
68
+ _(namo.formulae).must_be_kind_of Namo::Formulae
69
+ _(namo.formulae.keys).must_equal [:y]
70
+ end
71
+
72
+ it "passes keyword formulae already given as a Formulae through unchanged" do
73
+ formulae = Namo::Formulae.new({y: proc{|r| r[:x] * 2}})
74
+ _(Namo.new(formulae: formulae).formulae).must_be_same_as formulae
75
+ end
76
+
77
+ it "honours an explicit empty positional array over the nil sentinel" do
78
+ _(Namo.new([]).data).must_equal []
79
+ end
80
+
81
+ it "accepts data by keyword" do
82
+ _(Namo.new(data: [{x: 1}]).data).must_equal [{x: 1}]
83
+ end
84
+
85
+ it "lets positional data win when both positional and keyword data are given" do
86
+ _(Namo.new([{x: 1}], data: [{x: 2}]).data).must_equal [{x: 1}]
87
+ end
88
+
89
+ it "survives a round-trip through a set operator" do
90
+ a = Namo.new([{x: 1}])
91
+ b = Namo.new([{x: 2}])
92
+ _((a + b).data).must_equal [{x: 1}, {x: 2}]
93
+ end
94
+
95
+ it "survives a round-trip through an Enumerable method" do
96
+ namo = Namo.new([{x: 1}, {x: 2}])
97
+ _(namo.select{|row| row[:x] > 1}.data).must_equal [{x: 2}]
98
+ end
99
+ end
100
+
101
+ describe "#name" do
102
+ it "stores a name passed by keyword" do
103
+ _(Namo.new([{x: 1}], name: :foo).name).must_equal :foo
104
+ end
105
+
106
+ it "defaults to nil when no name is passed" do
107
+ _(Namo.new([{x: 1}]).name).must_be_nil
108
+ end
109
+
110
+ it "is settable post-construction" do
111
+ namo = Namo.new([{x: 1}])
112
+ namo.name = :bar
113
+ _(namo.name).must_equal :bar
114
+ end
115
+
116
+ it "is nil on a Namo derived from a set operator" do
117
+ a = Namo.new([{x: 1}], name: :a)
118
+ b = Namo.new([{x: 2}], name: :b)
119
+ _((a + b).name).must_be_nil
120
+ end
121
+
122
+ it "is nil on a Namo derived from an Enumerable method" do
123
+ namo = Namo.new([{x: 1}, {x: 2}], name: :original)
124
+ _(namo.select{|row| row[:x] > 1}.name).must_be_nil
125
+ end
126
+ end
127
+
128
+ describe "subclass side-effect guard" do
129
+ before do
130
+ @guard_class = Class.new(Namo) do
131
+ def self.fired
132
+ @fired ||= []
133
+ end
134
+ def initialize(positional_data = nil, data: [], formulae: {}, name: nil)
135
+ super
136
+ return unless name
137
+ self.class.fired << name
138
+ end
139
+ end
140
+ end
141
+
142
+ it "fires guarded side effects for an explicitly named construction" do
143
+ @guard_class.new(data: [{x: 1}], name: :foo)
144
+ _(@guard_class.fired).must_equal [:foo]
145
+ end
146
+
147
+ it "skips guarded side effects for an unnamed construction" do
148
+ @guard_class.new(data: [{x: 1}])
149
+ _(@guard_class.fired).must_equal []
150
+ end
151
+
152
+ it "skips guarded side effects for an operator result" do
153
+ a = @guard_class.new(data: [{x: 1}], name: :a)
154
+ b = @guard_class.new(data: [{x: 2}], name: :b)
155
+ @guard_class.fired.clear
156
+ _((a + b).name).must_be_nil
157
+ _(@guard_class.fired).must_equal []
158
+ end
159
+ end
160
+
161
+ describe "#dimensions" do
162
+ it "infers dimensions from hash keys" do
163
+ _(sales.dimensions).must_equal [:product, :quarter, :price, :quantity]
164
+ end
165
+
166
+ it "includes derived dimensions after storage dimensions" do
167
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
168
+ sales[:label] = proc{|r| "#{r[:product]}-#{r[:quarter]}"}
169
+ _(sales.dimensions).must_equal [:product, :quarter, :price, :quantity, :revenue, :label]
170
+ end
171
+
172
+ it "reflects mutation on the next call" do
173
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
174
+ _(sales.dimensions).must_include :revenue
175
+ sales.detach(:revenue)
176
+ _(sales.dimensions).wont_include :revenue
177
+ end
178
+ end
179
+
180
+ describe "#data_dimensions" do
181
+ it "returns only the storage keys" do
182
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
183
+ _(sales.data_dimensions).must_equal [:product, :quarter, :price, :quantity]
184
+ end
185
+ end
186
+
187
+ describe "#derived_dimensions" do
188
+ it "returns only the formula keys" do
189
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
190
+ _(sales.derived_dimensions).must_equal [:revenue]
191
+ end
192
+
193
+ it "is empty when no formulae are defined" do
194
+ _(sales.derived_dimensions).must_equal []
195
+ end
196
+ end
197
+
198
+ describe "#coordinates" do
199
+ it "with no args returns a Hash of unique values for each dimension" do
200
+ _(sales.coordinates).must_equal ({
201
+ product: ['Widget', 'Gadget'],
202
+ quarter: ['Q1', 'Q2'],
203
+ price: [10.0, 25.0],
204
+ quantity: [100, 150, 40, 60]
205
+ })
206
+ end
207
+
208
+ it "0.6.0-style indexing still works" do
209
+ _(sales.coordinates[:product]).must_equal ['Widget', 'Gadget']
210
+ _(sales.coordinates[:quarter]).must_equal ['Q1', 'Q2']
211
+ end
212
+
213
+ it "with one arg returns just that column's unique values as an Array" do
214
+ _(sales.coordinates(:product)).must_equal ['Widget', 'Gadget']
215
+ end
216
+
217
+ it "with one arg returns [nil] for an unknown dimension (nil values uniqued)" do
218
+ _(sales.coordinates(:missing)).must_equal [nil]
219
+ end
220
+
221
+ it "with one arg evaluates a derived dimension" do
222
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
223
+ _(sales.coordinates(:revenue)).must_equal [1000.0, 1500.0]
224
+ end
225
+
226
+ it "with multiple args returns a subset Hash" do
227
+ _(sales.coordinates(:product, :quarter)).must_equal({
228
+ product: ['Widget', 'Gadget'],
229
+ quarter: ['Q1', 'Q2']
230
+ })
231
+ end
232
+
233
+ it "with multiple args includes unknown dimensions as [nil]" do
234
+ _(sales.coordinates(:product, :missing)).must_equal({
235
+ product: ['Widget', 'Gadget'],
236
+ missing: [nil]
237
+ })
238
+ end
239
+
240
+ it "covers derived dimensions in the no-arg form" do
241
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
242
+ _(sales.coordinates[:revenue]).must_equal [1000.0, 1500.0]
243
+ end
244
+ end
245
+
246
+ describe "#values" do
247
+ it "with no args returns a Hash of full sequences for each dimension" do
248
+ _(sales.values).must_equal({
249
+ product: ['Widget', 'Widget', 'Gadget', 'Gadget'],
250
+ quarter: ['Q1', 'Q2', 'Q1', 'Q2'],
251
+ price: [10.0, 10.0, 25.0, 25.0],
252
+ quantity: [100, 150, 40, 60]
253
+ })
254
+ end
255
+
256
+ it "with one arg returns just that column as an Array, preserving duplicates and order" do
257
+ _(sales.values(:product)).must_equal ['Widget', 'Widget', 'Gadget', 'Gadget']
258
+ _(sales.values(:price)).must_equal [10.0, 10.0, 25.0, 25.0]
259
+ end
260
+
261
+ it "with one arg returns an Array of nils for an unknown dimension (one nil per row)" do
262
+ _(sales.values(:missing)).must_equal [nil, nil, nil, nil]
263
+ end
264
+
265
+ it "with one arg evaluates a derived dimension across all rows" do
266
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
267
+ _(sales.values(:revenue)).must_equal [1000.0, 1500.0, 1000.0, 1500.0]
268
+ end
269
+
270
+ it "with multiple args returns a subset Hash" do
271
+ _(sales.values(:product, :quarter)).must_equal({
272
+ product: ['Widget', 'Widget', 'Gadget', 'Gadget'],
273
+ quarter: ['Q1', 'Q2', 'Q1', 'Q2']
274
+ })
275
+ end
276
+
277
+ it "with multiple args includes unknown dimensions as Arrays of nils" do
278
+ _(sales.values(:product, :missing)).must_equal({
279
+ product: ['Widget', 'Widget', 'Gadget', 'Gadget'],
280
+ missing: [nil, nil, nil, nil]
281
+ })
282
+ end
283
+
284
+ it "covers derived dimensions in the no-arg form" do
285
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
286
+ _(sales.values[:revenue]).must_equal [1000.0, 1500.0, 1000.0, 1500.0]
287
+ end
288
+ end
289
+
290
+ describe "access-scoped materialisation" do
291
+ it "materialises a referenced column once per access, not once per referencing row" do
292
+ calls = Hash.new(0)
293
+ namo = Namo.new([{n: 1}, {n: 2}, {n: 3}])
294
+ namo[:base] = proc{|row| calls[:base] += 1; row[:n] * 10}
295
+ namo[:sum_base] = proc{|row, namo| namo.values(:base).sum}
296
+ _(namo.values(:sum_base)).must_equal [60, 60, 60]
297
+ _(calls[:base]).must_equal 3
298
+ end
299
+
300
+ it "retains nothing across accesses — a later access recomputes from current state" do
301
+ namo = Namo.new([{n: 1}, {n: 2}])
302
+ namo[:total] = proc{|row, namo| namo.values(:n).sum}
303
+ _(namo.values(:total)).must_equal [3, 3]
304
+ namo.data << {n: 4}
305
+ _(namo.values(:total)).must_equal [7, 7, 7]
306
+ end
307
+
308
+ it "clears the access scope when materialisation raises, leaving later accesses working" do
309
+ namo = Namo.new([{n: 1}])
310
+ namo[:boom] = proc{|row| raise 'materialisation blew up'}
311
+ _(->{namo.values(:boom)}).must_raise RuntimeError
312
+ namo[:ok] = proc{|row| row[:n]}
313
+ _(namo.values(:ok)).must_equal [1]
314
+ end
315
+
316
+ it "keeps one scope when a formula's nested values call runs during a multi-dimension access" do
317
+ calls = 0
318
+ namo = Namo.new([{x: 1}, {x: 2}, {x: 3}])
319
+ namo[:base] = proc{|row| calls += 1; row[:x] * 2}
320
+ namo[:agg] = proc{|row, namo| namo.values(:base).first}
321
+ # :agg's formula calls values(:base) mid-materialisation; that nested call must
322
+ # reuse the open scope, not reset it, so the :x column that follows still builds.
323
+ _(namo.values(:agg, :x)).must_equal(agg: [2, 2, 2], x: [1, 2, 3])
324
+ _(calls).must_equal 3
325
+ end
326
+ end
327
+
328
+ describe "#to_h" do
329
+ it "returns the full values Hash" do
330
+ _(sales.to_h).must_equal sales.values
331
+ end
332
+ end
333
+
334
+ describe "aspect consistency" do
335
+ it "satisfies coordinates(dim) == values(dim).uniq for each dimension" do
336
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
337
+ sales.dimensions.each do |dim|
338
+ _(sales.coordinates(dim)).must_equal sales.values(dim).uniq
339
+ end
340
+ end
341
+ end
342
+
343
+ describe "live-view semantics" do
344
+ it "reflects added rows on next call" do
345
+ _(sales.values(:product)).must_equal ['Widget', 'Widget', 'Gadget', 'Gadget']
346
+ sales.data << {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
347
+ _(sales.values(:product)).must_equal ['Widget', 'Widget', 'Gadget', 'Gadget', 'Thingo']
348
+ end
349
+
350
+ it "reflects added formulae on next call" do
351
+ _(sales.derived_dimensions).must_equal []
352
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
353
+ _(sales.derived_dimensions).must_equal [:revenue]
354
+ _(sales.coordinates(:revenue)).must_equal [1000.0, 1500.0]
355
+ end
356
+ end
357
+
358
+ describe "#[]" do
359
+ context "selection" do
360
+ it "selects by single coordinate" do
361
+ result = sales[product: 'Widget']
362
+ _(result.coordinates[:product]).must_equal ['Widget']
363
+ _(result.to_a.count).must_equal 2
364
+ _(result.to_a.map{|row| row[:product]}).must_equal ['Widget', 'Widget']
365
+ end
366
+
367
+ it "selects by array of coordinates" do
368
+ result = sales[quarter: ['Q1']]
369
+ _(result.coordinates[:quarter]).must_equal ['Q1']
370
+ _(result.to_a.count).must_equal 2
371
+ end
372
+
373
+ it "selects by multiple dimensions" do
374
+ result = sales[product: 'Widget', quarter: 'Q1']
375
+ _(result.to_a.count).must_equal 1
376
+ _(result.to_a).must_equal [{product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100}]
377
+ end
378
+
379
+ it "returns a Namo instance" do
380
+ result = sales[product: 'Widget']
381
+ _(result.coordinates[:product]).must_equal ['Widget']
382
+ end
383
+ end
384
+
385
+ context "projection" do
386
+ it "projects to named dimensions" do
387
+ result = sales[:product, :price]
388
+ _(result.dimensions).must_equal [:product, :price]
389
+ _(result.coordinates[:product]).must_equal ['Widget', 'Gadget']
390
+ _(result.to_a.count).must_equal 4
391
+ end
392
+ end
393
+
394
+ context "selection and projection" do
395
+ it "can use them together" do
396
+ result = sales[:price, product: 'Widget']
397
+ _(result.to_a.count).must_equal 2
398
+ _(result.to_a).must_equal [{price: 10.0}, {price: 10.0}]
399
+ end
400
+
401
+ it "can chain them" do
402
+ result = sales[product: 'Widget'][:price]
403
+ _(result.to_a.count).must_equal 2
404
+ _(result.to_a).must_equal [{price: 10.0}, {price: 10.0}]
405
+ end
406
+ end
407
+
408
+ context "contraction" do
409
+ it "removes named dimensions" do
410
+ result = sales[-:price, -:quantity]
411
+ _(result.dimensions).must_equal [:product, :quarter]
412
+ _(result.to_a.count).must_equal 4
413
+ _(result.to_a.first).must_equal({product: 'Widget', quarter: 'Q1'})
414
+ end
415
+
416
+ it "removes a single dimension" do
417
+ result = sales[-:price]
418
+ _(result.dimensions).must_equal [:product, :quarter, :quantity]
419
+ _(result.to_a.count).must_equal 4
420
+ end
421
+
422
+ it "raises when mixing projection and contraction" do
423
+ _ { sales[:product, -:price] }.must_raise ArgumentError
424
+ end
425
+
426
+ it "carries formulae through contraction" do
427
+ sales[:label] = proc{|r| "#{r[:product]}-#{r[:quarter]}"}
428
+ result = sales[-:price, -:quantity]
429
+ _(result.map{|row| row[:label]}).must_equal [
430
+ 'Widget-Q1', 'Widget-Q2', 'Gadget-Q1', 'Gadget-Q2'
431
+ ]
432
+ end
433
+ end
434
+
435
+ context "selection and contraction" do
436
+ it "can use them together" do
437
+ result = sales[-:price, -:quantity, product: 'Widget']
438
+ _(result.to_a.count).must_equal 2
439
+ _(result.to_a).must_equal [
440
+ {product: 'Widget', quarter: 'Q1'},
441
+ {product: 'Widget', quarter: 'Q2'}
442
+ ]
443
+ end
444
+
445
+ it "can chain them" do
446
+ result = sales[product: 'Widget'][-:price, -:quantity]
447
+ _(result.to_a.count).must_equal 2
448
+ _(result.to_a).must_equal [
449
+ {product: 'Widget', quarter: 'Q1'},
450
+ {product: 'Widget', quarter: 'Q2'}
451
+ ]
452
+ end
453
+ end
454
+
455
+ context "proc selection" do
456
+ it "selects rows where the proc returns truthy" do
457
+ result = sales[price: ->(v){v < 15.0}]
458
+ _(result.to_a.count).must_equal 2
459
+ _(result.to_a.map{|row| row[:product]}).must_equal ['Widget', 'Widget']
460
+ end
461
+
462
+ it "selects on multiple proc dimensions" do
463
+ result = sales[price: ->(v){v < 30.0}, quantity: ->(v){v > 50}]
464
+ _(result.to_a).must_equal [
465
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
466
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
467
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
468
+ ]
469
+ end
470
+
471
+ it "composes with projection in a single call" do
472
+ result = sales[:product, :price, price: ->(v){v < 15.0}]
473
+ _(result.to_a).must_equal [
474
+ {product: 'Widget', price: 10.0},
475
+ {product: 'Widget', price: 10.0}
476
+ ]
477
+ end
478
+
479
+ it "composes with contraction in a single call" do
480
+ result = sales[-:quantity, price: ->(v){v < 15.0}]
481
+ _(result.to_a).must_equal [
482
+ {product: 'Widget', quarter: 'Q1', price: 10.0},
483
+ {product: 'Widget', quarter: 'Q2', price: 10.0}
484
+ ]
485
+ end
486
+
487
+ it "selects on a formula-defined dimension" do
488
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
489
+ result = sales[revenue: ->(v){v >= 1500.0}]
490
+ _(result.to_a).must_equal [
491
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150, revenue: 1500.0},
492
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60, revenue: 1500.0}
493
+ ]
494
+ end
495
+ end
496
+
497
+ context "regex selection" do
498
+ it "selects by regex against String values" do
499
+ result = sales[product: /^W/]
500
+ _(result.to_a.count).must_equal 2
501
+ _(result.to_a.map{|row| row[:product]}).must_equal ['Widget', 'Widget']
502
+ end
503
+
504
+ it "supports case-insensitive matching" do
505
+ result = sales[product: /widget/i]
506
+ _(result.to_a.count).must_equal 2
507
+ end
508
+
509
+ it "supports alternation" do
510
+ result = sales[product: /Widget|Gadget/]
511
+ _(result.to_a.count).must_equal 4
512
+ end
513
+
514
+ it "coerces non-String values via to_s" do
515
+ result = sales[quantity: /^1/]
516
+ _(result.to_a.map{|row| row[:quantity]}).must_equal [100, 150]
517
+ end
518
+
519
+ it "composes with an exact value on another dimension" do
520
+ result = sales[product: /^W/, quarter: 'Q1']
521
+ _(result.to_a).must_equal [
522
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100}
523
+ ]
524
+ end
525
+
526
+ it "composes with projection in a single call" do
527
+ result = sales[:product, :quarter, product: /^W/]
528
+ _(result.to_a).must_equal [
529
+ {product: 'Widget', quarter: 'Q1'},
530
+ {product: 'Widget', quarter: 'Q2'}
531
+ ]
532
+ end
533
+
534
+ it "composes with contraction in a single call" do
535
+ result = sales[-:price, -:quantity, product: /^W/]
536
+ _(result.to_a).must_equal [
537
+ {product: 'Widget', quarter: 'Q1'},
538
+ {product: 'Widget', quarter: 'Q2'}
539
+ ]
540
+ end
541
+
542
+ it "selects on a formula-defined dimension" do
543
+ sales[:label] = proc{|r| "#{r[:product]}-#{r[:quarter]}"}
544
+ result = sales[label: /Widget/]
545
+ _(result.to_a.count).must_equal 2
546
+ _(result.map{|row| row[:label]}).must_equal ['Widget-Q1', 'Widget-Q2']
547
+ end
548
+ end
549
+
550
+ context "range selection" do
551
+ it "selects rows whose value falls within the range" do
552
+ result = sales[price: 5.0..15.0]
553
+ _(result.to_a.count).must_equal 2
554
+ _(result.to_a.map{|row| row[:product]}).must_equal ['Widget', 'Widget']
555
+ end
556
+
557
+ it "supports beginless and endless ranges" do
558
+ _(sales[price: ..15.0].to_a.count).must_equal 2
559
+ _(sales[quantity: 100..].to_a.count).must_equal 2
560
+ end
561
+
562
+ it "composes with projection in a single call" do
563
+ result = sales[:product, :quantity, quantity: 50..120]
564
+ _(result.to_a).must_equal [
565
+ {product: 'Widget', quantity: 100},
566
+ {product: 'Gadget', quantity: 60}
567
+ ]
568
+ end
569
+
570
+ it "selects on a formula-defined dimension" do
571
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
572
+ result = sales[revenue: 1200.0..]
573
+ _(result.to_a).must_equal [
574
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150, revenue: 1500.0},
575
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60, revenue: 1500.0}
576
+ ]
577
+ end
578
+ end
579
+
580
+ context "mixed proc and regex selection" do
581
+ it "combines a proc and a regex across dimensions" do
582
+ result = sales[product: /^W/, quantity: ->(v){v > 100}]
583
+ _(result.to_a).must_equal [
584
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150}
585
+ ]
586
+ end
587
+ end
588
+ end
589
+
590
+ describe "#[]= formulae" do
591
+ it "defines a formula" do
592
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
593
+ _(sales[:product, :quarter, :revenue].to_a).must_equal [
594
+ {product: "Widget", quarter: "Q1", revenue: 1000.0},
595
+ {product: "Widget", quarter: "Q2", revenue: 1500.0},
596
+ {product: "Gadget", quarter: "Q1", revenue: 1000.0},
597
+ {product: "Gadget", quarter: "Q2", revenue: 1500.0}
598
+ ]
599
+ end
600
+
601
+ it "composes formulae" do
602
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
603
+ sales[:cost] = proc{|r| r[:quantity] * 4.0}
604
+ sales[:profit] = proc{|r| r[:revenue] - r[:cost]}
605
+ _(sales[:product, :quarter, :profit].data).must_equal [
606
+ {product: "Widget", quarter: "Q1", profit: 600.0},
607
+ {product: "Widget", quarter: "Q2", profit: 900.0},
608
+ {product: "Gadget", quarter: "Q1", profit: 840.0},
609
+ {product: "Gadget", quarter: "Q2", profit: 1260.0}
610
+ ]
611
+ end
612
+
613
+ it "works with chained selection and projection" do
614
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
615
+ result = sales[product: 'Widget'][:product, :quarter, :revenue]
616
+ _(result.to_a).must_equal [
617
+ {product: "Widget", quarter: "Q1", revenue: 1000.0},
618
+ {product: "Widget", quarter: "Q2", revenue: 1500.0}
619
+ ]
620
+ end
621
+
622
+ it "works with single-call selection and projection" do
623
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
624
+ result = sales[:product, :revenue, product: 'Widget']
625
+ _(result.to_a).must_equal [
626
+ {product: "Widget", revenue: 1000.0},
627
+ {product: "Widget", revenue: 1500.0}
628
+ ]
629
+ end
630
+
631
+ it "carries formulae through selection" do
632
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
633
+ widgets = sales[product: 'Widget']
634
+ _(widgets[:revenue].to_a).must_equal [{revenue: 1000.0}, {revenue: 1500.0}]
635
+ end
636
+
637
+ it "projects formula with context dimensions" do
638
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
639
+ result = sales[:product, :quarter, :revenue]
640
+ _(result.to_a).must_equal [
641
+ {product: 'Widget', quarter: 'Q1', revenue: 1000.0},
642
+ {product: 'Widget', quarter: 'Q2', revenue: 1500.0},
643
+ {product: 'Gadget', quarter: 'Q1', revenue: 1000.0},
644
+ {product: 'Gadget', quarter: 'Q2', revenue: 1500.0}
645
+ ]
646
+ end
647
+ end
648
+
649
+ describe "#[]= polymorphic dispatch" do
650
+ it "registers a formula when assigned a proc (existing behaviour preserved)" do
651
+ namo = Namo.new([{price: 10.0, quantity: 100}])
652
+ namo[:revenue] = proc{|r| r[:price] * r[:quantity]}
653
+ _(namo.derived_dimensions).must_include :revenue
654
+ _(namo.values(:revenue)).must_equal [1000.0]
655
+ end
656
+
657
+ it "clears any data column of the same name when assigned a proc" do
658
+ namo = Namo.new([{x: 1}, {x: 2}])
659
+ _(namo.data_dimensions).must_include :x
660
+ namo[:x] = proc{|r| 99}
661
+ _(namo.data_dimensions).wont_include :x
662
+ _(namo.derived_dimensions).must_include :x
663
+ _(namo.values(:x)).must_equal [99, 99]
664
+ end
665
+
666
+ it "broadcasts a scalar to every row" do
667
+ namo = Namo.new([{a: 1}, {a: 2}, {a: 3}])
668
+ namo[:status] = 'active'
669
+ _(namo.values(:status)).must_equal ['active', 'active', 'active']
670
+ end
671
+
672
+ it "clears any formula of the same name when assigned a scalar" do
673
+ namo = Namo.new([{price: 10.0, quantity: 100}])
674
+ namo[:revenue] = proc{|r| r[:price] * r[:quantity]}
675
+ namo[:revenue] = 0
676
+ _(namo.derived_dimensions).wont_include :revenue
677
+ _(namo.data_dimensions).must_include :revenue
678
+ _(namo.values(:revenue)).must_equal [0]
679
+ end
680
+
681
+ it "broadcasts an array as the value (array is not a proc)" do
682
+ namo = Namo.new([{a: 1}, {a: 2}])
683
+ namo[:weights] = [1, 2, 3]
684
+ _(namo.values(:weights)).must_equal [[1, 2, 3], [1, 2, 3]]
685
+ end
686
+
687
+ it "registers a formula when assigned a Method (responds to call)" do
688
+ calculator = Object.new
689
+ def calculator.revenue(row); row[:price] * row[:quantity]; end
690
+ namo = Namo.new([{price: 10.0, quantity: 100}, {price: 5.0, quantity: 200}])
691
+ namo[:revenue] = calculator.method(:revenue)
692
+ _(namo.derived_dimensions).must_include :revenue
693
+ _(namo.data_dimensions).wont_include :revenue
694
+ _(namo.values(:revenue)).must_equal [1000.0, 1000.0]
695
+ end
696
+
697
+ it "registers a formula when assigned a lambda (responds to call)" do
698
+ namo = Namo.new([{x: 1}, {x: 2}])
699
+ namo[:doubled] = ->(r){r[:x] * 2}
700
+ _(namo.derived_dimensions).must_include :doubled
701
+ _(namo.data_dimensions).wont_include :doubled
702
+ _(namo.values(:doubled)).must_equal [2, 4]
703
+ end
704
+
705
+ it "is last-write-wins: scalar then proc leaves a formula only" do
706
+ namo = Namo.new([{y: 7}])
707
+ namo[:x] = 5
708
+ namo[:x] = proc{|r| r[:y]}
709
+ _(namo.derived_dimensions).must_include :x
710
+ _(namo.data_dimensions).wont_include :x
711
+ _(namo.values(:x)).must_equal [7]
712
+ end
713
+
714
+ it "is last-write-wins: proc then scalar leaves a broadcast value only" do
715
+ namo = Namo.new([{y: 7}])
716
+ namo[:x] = proc{|r| r[:y]}
717
+ namo[:x] = 5
718
+ _(namo.data_dimensions).must_include :x
719
+ _(namo.derived_dimensions).wont_include :x
720
+ _(namo.values(:x)).must_equal [5]
721
+ end
722
+
723
+ it "surfaces a proc-assigned name as derived, not data, exactly once in dimensions" do
724
+ namo = Namo.new([{x: 1}, {x: 2}])
725
+ namo[:x] = proc{|r| 99}
726
+ _(namo.data_dimensions).wont_include :x
727
+ _(namo.derived_dimensions).must_include :x
728
+ _(namo.dimensions.count(:x)).must_equal 1
729
+ end
730
+
731
+ it "surfaces a scalar-assigned name as data, not derived, exactly once in dimensions" do
732
+ namo = Namo.new([{x: 1}])
733
+ namo[:rev] = proc{|r| r[:x]}
734
+ namo[:rev] = 5
735
+ _(namo.data_dimensions).must_include :rev
736
+ _(namo.derived_dimensions).wont_include :rev
737
+ _(namo.dimensions.count(:rev)).must_equal 1
738
+ end
739
+
740
+ it "does not walk rows to clear when no data column of that name exists" do
741
+ namo = Namo.new([{a: 1}])
742
+ namo[:b] = proc{|r| r[:a]}
743
+ _(namo.derived_dimensions).must_include :b
744
+ _(namo.values(:b)).must_equal [1]
745
+ end
746
+
747
+ it "handles an empty Namo without error" do
748
+ namo = Namo.new([])
749
+ namo[:x] = proc{|r| 1}
750
+ _(namo.derived_dimensions).must_include :x
751
+ end
752
+ end
753
+
754
+ describe "formularies" do
755
+ let(:flow_data) do
756
+ [
757
+ {date: 1, buys: 60, sells: 40},
758
+ {date: 2, buys: 80, sells: 120},
759
+ {date: 3, buys: 75, sells: 75},
760
+ ]
761
+ end
762
+
763
+ let(:delta) do
764
+ Module.new do
765
+ include Namo::Formulary
766
+ def signed_volume(row); row[:buys] - row[:sells]; end
767
+ def cum_delta(row, namo); namo[date: ..row[:date]].values(:signed_volume).sum; end
768
+ def scaled_volume(row, namo, factor); (row[:buys] + row[:sells]) * factor; end
769
+ private
770
+ def helper(row); row[:buys]; end
771
+ end
772
+ end
773
+
774
+ let(:untagged) do
775
+ Module.new do
776
+ def signed_volume(row); 0; end
777
+ end
778
+ end
779
+
780
+ let(:flows) do
781
+ Namo.new(flow_data).attach(delta)
782
+ end
783
+
784
+ describe "#attach" do
785
+ it "returns the Namo" do
786
+ namo = Namo.new(flow_data)
787
+ _(namo.attach(delta)).must_be_same_as namo
788
+ end
789
+
790
+ it "raises ArgumentError for a module not tagged Namo::Formulary" do
791
+ _(proc{Namo.new(flow_data).attach(untagged)}).must_raise ArgumentError
792
+ end
793
+ end
794
+
795
+ describe "#attach!" do
796
+ it "attaches as attach does for a non-colliding module" do
797
+ namo = Namo.new(flow_data)
798
+ _(namo.attach!(delta)).must_be_same_as namo
799
+ _(namo.values(:signed_volume)).must_equal [20, -40, 0]
800
+ end
801
+
802
+ it "evicts a colliding data column and registers the formulary method in its place" do
803
+ widened = Namo.new(flow_data.map{|row| row.merge(signed_volume: 999)})
804
+ widened.attach!(delta)
805
+ _(widened.data_dimensions).wont_include :signed_volume
806
+ _(widened.derived_dimensions).must_include :signed_volume
807
+ _(widened.dimensions.count(:signed_volume)).must_equal 1
808
+ end
809
+
810
+ it "agrees across all access paths on the evicted-then-derived name" do
811
+ widened = Namo.new(flow_data.map{|row| row.merge(signed_volume: 999)})
812
+ widened.attach!(delta)
813
+ _(widened.values(:signed_volume)).must_equal [20, -40, 0]
814
+ _(widened.entries.first[:signed_volume]).must_equal 20
815
+ _(widened[signed_volume: 20].values(:date)).must_equal [1]
816
+ end
817
+
818
+ it "evicts every colliding column when several collide" do
819
+ clash = Module.new do
820
+ include Namo::Formulary
821
+ def buys(row); row[:date] * 2; end
822
+ def sells(row); row[:date] * 3; end
823
+ end
824
+ namo = Namo.new(flow_data)
825
+ namo.attach!(clash)
826
+ _(namo.data_dimensions).wont_include :buys
827
+ _(namo.data_dimensions).wont_include :sells
828
+ _(namo.derived_dimensions).must_include :buys
829
+ _(namo.derived_dimensions).must_include :sells
830
+ _(namo.values(:buys)).must_equal [2, 4, 6]
831
+ _(namo.values(:sells)).must_equal [3, 6, 9]
832
+ end
833
+
834
+ it "leaves the data untouched when the module does not collide" do
835
+ namo = Namo.new(flow_data)
836
+ namo.attach!(delta)
837
+ _(namo.values(:buys)).must_equal [60, 80, 75]
838
+ _(namo.values(:sells)).must_equal [40, 120, 75]
839
+ end
840
+
841
+ it "raises through attach (the guarded form) where attach! evicts" do
842
+ widened = Namo.new(flow_data.map{|row| row.merge(signed_volume: 999)})
843
+ _(proc{widened.attach(delta)}).must_raise ArgumentError
844
+ end
845
+ end
846
+
847
+ describe "#<<" do
848
+ it "attaches a formulary, the same as #attach" do
849
+ namo = Namo.new(flow_data)
850
+ namo << delta
851
+ _(namo.values(:signed_volume)).must_equal [20, -40, 0]
852
+ end
853
+
854
+ it "returns the Namo for chaining" do
855
+ namo = Namo.new(flow_data)
856
+ _(namo << delta).must_be_same_as namo
857
+ end
858
+
859
+ it "appends a Hash as a data row" do
860
+ namo = Namo.new(flow_data)
861
+ namo << {date: 4, buys: 10, sells: 5}
862
+ _(namo.values(:date)).must_equal [1, 2, 3, 4]
863
+ _(namo.values(:buys)).must_equal [60, 80, 75, 10]
864
+ end
865
+
866
+ it "returns the Namo when appending a row" do
867
+ namo = Namo.new(flow_data)
868
+ _(namo << {date: 4, buys: 10, sells: 5}).must_be_same_as namo
869
+ end
870
+
871
+ it "appends a Row's underlying hash as a data row" do
872
+ namo = Namo.new(flow_data)
873
+ row = Namo.new([{date: 4, buys: 10, sells: 5}]).entries.first
874
+ namo << row
875
+ _(namo.values(:date)).must_equal [1, 2, 3, 4]
876
+ end
877
+
878
+ it "raises a TypeError on a whole Namo, which is +'s job" do
879
+ namo = Namo.new(flow_data)
880
+ _(proc{namo << Namo.new(flow_data)}).must_raise TypeError
881
+ end
882
+
883
+ it "raises a TypeError on a bare callable, which is []='s job" do
884
+ namo = Namo.new(flow_data)
885
+ _(proc{namo << ->(row){row[:buys]}}).must_raise TypeError
886
+ end
887
+
888
+ it "chains mixed arms: rows and a formulary" do
889
+ namo = Namo.new(flow_data)
890
+ namo << {date: 4, buys: 10, sells: 5} << delta << {date: 5, buys: 0, sells: 0}
891
+ _(namo.values(:date)).must_equal [1, 2, 3, 4, 5]
892
+ _(namo.values(:signed_volume)).must_equal [20, -40, 0, 5, 0]
893
+ end
894
+
895
+ it "raises an ArgumentError when an appended Hash's keys collide with a formula" do
896
+ namo = Namo.new(flow_data).attach(delta)
897
+ error = _(proc{namo << {date: 4, buys: 10, sells: 5, signed_volume: 999}}).must_raise ArgumentError
898
+ _(error.message).must_match(/signed_volume/)
899
+ end
900
+
901
+ it "raises likewise when an appended Row's keys collide with a formula" do
902
+ namo = Namo.new(flow_data).attach(delta)
903
+ row = Namo.new([{date: 4, buys: 10, sells: 5, signed_volume: 999}]).entries.first
904
+ _(proc{namo << row}).must_raise ArgumentError
905
+ end
906
+ end
907
+
908
+ describe "#derived_dimensions" do
909
+ it "lists the formulary's public methods alongside store keys, unique" do
910
+ _(flows.derived_dimensions.sort).must_equal [:cum_delta, :scaled_volume, :signed_volume]
911
+ end
912
+
913
+ it "does not surface a private helper" do
914
+ _(flows.derived_dimensions).wont_include :helper
915
+ end
916
+
917
+ it "is empty for a vanilla Namo — no Namo public API leaks as a dimension" do
918
+ _(Namo.new(flow_data).derived_dimensions).must_equal []
919
+ end
920
+ end
921
+
922
+ describe "resolution" do
923
+ it "resolves a row-scoped formulary method through values" do
924
+ _(flows.values(:signed_volume)).must_equal [20, -40, 0]
925
+ end
926
+
927
+ it "resolves a row-scoped formulary method through a Row" do
928
+ _(flows.entries.first[:signed_volume]).must_equal 20
929
+ end
930
+
931
+ it "selects on a formulary-defined dimension" do
932
+ _(flows[signed_volume: 20].values(:date)).must_equal [1]
933
+ end
934
+
935
+ it "windows a two-arity formulary method over the yielding Namo" do
936
+ _(flows.values(:cum_delta)).must_equal [20, -20, -20]
937
+ end
938
+
939
+ it "raises the collection-scoped-context error on a Row built without a Namo" do
940
+ row = Namo::Row.new(flow_data.first, flows.formulae)
941
+ _(proc{row[:cum_delta]}).must_raise ArgumentError
942
+ end
943
+ end
944
+
945
+ describe "parameterised formulary method" do
946
+ it "is omitted from no-arg bulk views" do
947
+ _(flows.values.keys).wont_include :scaled_volume
948
+ end
949
+
950
+ it "raises when named directly with no argument" do
951
+ _(proc{flows.values(:scaled_volume)}).must_raise ArgumentError
952
+ end
953
+
954
+ it "materialises through a row-scoped wrapper that supplies the argument" do
955
+ flows[:double_flow] = ->(r){r[:scaled_volume, 2]}
956
+ _(flows.values(:double_flow)).must_equal [200, 400, 300]
957
+ end
958
+ end
959
+
960
+ describe "live re-attachment" do
961
+ it "resolves to the most-recently attached formulary on a name collision" do
962
+ alt = Module.new do
963
+ include Namo::Formulary
964
+ def signed_volume(row); row[:buys]; end
965
+ end
966
+ namo = Namo.new(flow_data).attach(delta).attach(alt)
967
+ _(namo.values(:signed_volume)).must_equal [60, 80, 75]
968
+ end
969
+ end
970
+
971
+ describe "precedence" do
972
+ it "lets an instance []= formula shadow a formulary method of the same name" do
973
+ flows[:signed_volume] = ->(r){0}
974
+ _(flows.values(:signed_volume)).must_equal [0, 0, 0]
975
+ end
976
+ end
977
+
978
+ describe "carry-through" do
979
+ it "carries the formulary live through selection" do
980
+ _(flows.select{|row| row[:date] >= 2}.values(:signed_volume)).must_equal [-40, 0]
981
+ end
982
+
983
+ it "carries the formulary live through projection by selection" do
984
+ _(flows[date: 1..2].values(:signed_volume)).must_equal [20, -40]
985
+ end
986
+
987
+ it "carries the formulary through concatenation" do
988
+ _((flows + Namo.new(flow_data)).derived_dimensions).must_include :signed_volume
989
+ end
990
+ end
991
+
992
+ describe "materialising a formulary dimension by projection" do
993
+ it "lists the name once, as data, not derived" do
994
+ proj = flows[:date, :signed_volume]
995
+ _(proj.data_dimensions).must_include :signed_volume
996
+ _(proj.derived_dimensions).wont_include :signed_volume
997
+ _(proj.dimensions.count(:signed_volume)).must_equal 1
998
+ end
999
+
1000
+ it "reads the snapshot, not a recomputation, on per-Row access" do
1001
+ proj = flows[:date, :signed_volume]
1002
+ _(proj.entries.first[:signed_volume]).must_equal 20
1003
+ _(proj.values(:signed_volume)).must_equal [20, -40, 0]
1004
+ end
1005
+
1006
+ it "raises when a formulary method collides with a data dimension" do
1007
+ widened = Namo.new(flow_data.map{|row| row.merge(signed_volume: 999)})
1008
+ error = _(proc{widened.attach(delta)}).must_raise ArgumentError
1009
+ _(error.message).must_match(/signed_volume/)
1010
+ end
1011
+ end
1012
+
1013
+ describe "#===" do
1014
+ it "is not === to a vanilla Namo of the same data dimensions" do
1015
+ _(flows === Namo.new(flow_data)).must_equal false
1016
+ end
1017
+
1018
+ it "is === to a Namo exposing the same names via []=" do
1019
+ other = Namo.new(flow_data)
1020
+ other[:signed_volume] = ->(r){0}
1021
+ other[:cum_delta] = ->(r, n){0}
1022
+ other[:scaled_volume] = ->(r, n, f){0}
1023
+ _(flows === other).must_equal true
1024
+ end
1025
+ end
1026
+
1027
+ describe "#detach" do
1028
+ it "returns the Namo" do
1029
+ _(flows.detach(:signed_volume)).must_be_same_as flows
1030
+ end
1031
+
1032
+ it "removes a formula from the queryable namespace" do
1033
+ namo = Namo.new(flow_data)
1034
+ namo[:revenue] = proc{|r| r[:buys]}
1035
+ namo.detach(:revenue)
1036
+ _(namo.derived_dimensions).wont_include :revenue
1037
+ _(namo.values(:revenue)).must_equal [nil, nil, nil]
1038
+ end
1039
+
1040
+ it "leaves other formulae resolving" do
1041
+ namo = Namo.new(flow_data)
1042
+ namo[:revenue] = proc{|r| r[:buys]}
1043
+ namo[:tally] = proc{|r| r[:sells]}
1044
+ namo.detach(:revenue)
1045
+ _(namo.values(:tally)).must_equal [40, 120, 75]
1046
+ end
1047
+
1048
+ it "removes an attached formulary's methods via the Module arm" do
1049
+ flows.detach(delta)
1050
+ _(flows.derived_dimensions).must_equal []
1051
+ end
1052
+
1053
+ it "never exposed the formulary's private helper" do
1054
+ _(flows.derived_dimensions).wont_include :helper
1055
+ end
1056
+
1057
+ it "never touches data — a data dimension is a no-op" do
1058
+ flows.detach(:date)
1059
+ _(flows.data_dimensions).must_include :date
1060
+ _(flows.values(:date)).must_equal [1, 2, 3]
1061
+ end
1062
+
1063
+ it "is a no-op on an absent name, returning the Namo" do
1064
+ _(flows.detach(:missing)).must_be_same_as flows
1065
+ _(flows.values(:signed_volume)).must_equal [20, -40, 0]
1066
+ end
1067
+
1068
+ it "raises TypeError on a non-Symbol non-Module" do
1069
+ _(proc{flows.detach("signed_volume")}).must_raise TypeError
1070
+ end
1071
+
1072
+ it "raises ArgumentError on an untagged module" do
1073
+ _(proc{flows.detach(untagged)}).must_raise ArgumentError
1074
+ end
1075
+ end
1076
+
1077
+ describe "private support methods" do
1078
+ it "resolves a public formulary method through its private helper" do
1079
+ namo = Namo.new(flow_data).attach(SupportedFlow)
1080
+ _(namo.values(:net_volume)).must_equal [20, -40, 0]
1081
+ _(namo.derived_dimensions).must_equal [:net_volume]
1082
+ end
1083
+
1084
+ it "resolves an anonymous formulary's public method through its private helper (carrier-collected, host-resolved)" do
1085
+ anonymous = Module.new do
1086
+ include Namo::Formulary
1087
+ def net_volume(row); gross(row) - row[:sells] * 2; end
1088
+ private
1089
+ def gross(row); row[:buys] + row[:sells]; end
1090
+ end
1091
+ namo = Namo.new(flow_data).attach(anonymous)
1092
+ _(namo.values(:net_volume)).must_equal [20, -40, 0]
1093
+ end
1094
+
1095
+ it "lets a later-attached formulary's same-named private helper shadow the earlier one (single shared host)" do
1096
+ shadower = Module.new do
1097
+ include Namo::Formulary
1098
+ def momentum(row); 0; end
1099
+ private
1100
+ def gross(row); 0; end
1101
+ end
1102
+ namo = Namo.new(flow_data).attach(SupportedFlow).attach(shadower)
1103
+ _(namo.values(:net_volume)).must_equal [-80, -240, -150]
1104
+ end
1105
+ end
1106
+ end
1107
+
1108
+ describe "formularies via class include" do
1109
+ let(:flow_data) do
1110
+ [{buys: 60, sells: 40}]
1111
+ end
1112
+
1113
+ let(:indicators) do
1114
+ Module.new do
1115
+ include Namo::Formulary
1116
+ def signed_volume(row); row[:buys] - row[:sells]; end
1117
+ def label(row); "indicators"; end
1118
+ private
1119
+ def helper(row); row[:buys]; end
1120
+ end
1121
+ end
1122
+
1123
+ let(:scoring) do
1124
+ Module.new do
1125
+ include Namo::Formulary
1126
+ def momentum(row); row[:buys] + row[:sells]; end
1127
+ def label(row); "scoring"; end
1128
+ end
1129
+ end
1130
+
1131
+ let(:analysis_class) do
1132
+ klass = Class.new(Namo)
1133
+ klass.include(indicators)
1134
+ klass.include(scoring)
1135
+ klass
1136
+ end
1137
+
1138
+ it "lists an included formulary's public methods as derived dimensions" do
1139
+ _(analysis_class.new(flow_data).derived_dimensions.sort).must_equal [:label, :momentum, :signed_volume]
1140
+ end
1141
+
1142
+ it "excludes a private helper" do
1143
+ _(analysis_class.new(flow_data).derived_dimensions).wont_include :helper
1144
+ end
1145
+
1146
+ it "resolves an included formulary method" do
1147
+ _(analysis_class.new(flow_data).values(:signed_volume)).must_equal [20]
1148
+ end
1149
+
1150
+ it "lets the most-recently included formulary win on a name collision" do
1151
+ _(analysis_class.new(flow_data).values(:label)).must_equal ["scoring"]
1152
+ end
1153
+
1154
+ it "raises at construction when an included formulary collides with a data dimension" do
1155
+ clash = Module.new do
1156
+ include Namo::Formulary
1157
+ def buys(row); 0; end
1158
+ end
1159
+ klass = Class.new(Namo)
1160
+ klass.include(clash)
1161
+ _(proc{klass.new([{buys: 5}])}).must_raise ArgumentError
1162
+ end
1163
+
1164
+ it "reaches an instance built before a later include only through attach" do
1165
+ klass = Class.new(Namo)
1166
+ instance = klass.new(flow_data)
1167
+ klass.include(scoring)
1168
+ _(instance.derived_dimensions).wont_include :momentum
1169
+ _(klass.new(flow_data).derived_dimensions).must_include :momentum
1170
+ instance.attach(scoring)
1171
+ _(instance.derived_dimensions).must_include :momentum
1172
+ end
1173
+ end
1174
+
1175
+ describe "#[]= two-arity formulae" do
1176
+ let(:price_data) do
1177
+ [
1178
+ {symbol: 'AAA', date: 1, close: 10.0},
1179
+ {symbol: 'AAA', date: 2, close: 20.0},
1180
+ {symbol: 'AAA', date: 3, close: 30.0},
1181
+ ]
1182
+ end
1183
+
1184
+ let(:prices) do
1185
+ Namo.new(price_data)
1186
+ end
1187
+
1188
+ # A simple moving average: the mean close over the rows of the same symbol
1189
+ # up to and including the current row's date, computed against the Namo the
1190
+ # row belongs to.
1191
+ let(:sma) do
1192
+ ->(row, namo){
1193
+ window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}]
1194
+ window.values(:close).sum / window.count.to_f
1195
+ }
1196
+ end
1197
+
1198
+ it "resolves a cross-row SMA via values" do
1199
+ prices[:sma] = sma
1200
+ _(prices.values(:sma)).must_equal [10.0, 15.0, 20.0]
1201
+ end
1202
+
1203
+ it "resolves through coordinates" do
1204
+ prices[:sma] = sma
1205
+ _(prices.coordinates(:sma)).must_equal [10.0, 15.0, 20.0]
1206
+ end
1207
+
1208
+ it "resolves through the no-arg to_h" do
1209
+ prices[:sma] = sma
1210
+ _(prices.to_h[:sma]).must_equal [10.0, 15.0, 20.0]
1211
+ end
1212
+
1213
+ it "selects on the two-arity dimension" do
1214
+ prices[:sma] = sma
1215
+ _(prices[sma: ->(v){v > 12.0}].values(:date)).must_equal [2, 3]
1216
+ end
1217
+
1218
+ it "resolves a two-arity dimension in a subset-method predicate" do
1219
+ prices[:sma] = sma
1220
+ result = prices.select{|row| row[:sma] > 12.0}
1221
+ _(result).must_be_kind_of Namo
1222
+ _(result.values(:date)).must_equal [2, 3]
1223
+ end
1224
+
1225
+ it "resolves the two-arity formula on the no-arg first Row" do
1226
+ prices[:sma] = sma
1227
+ _(prices.first[:sma]).must_equal 10.0
1228
+ end
1229
+
1230
+ it "resolves the two-arity formula on the no-arg last Row" do
1231
+ prices[:sma] = sma
1232
+ _(prices.last[:sma]).must_equal 20.0
1233
+ end
1234
+
1235
+ it "windows over the yielding Namo — a filtered Namo computes over the filtered rows only" do
1236
+ prices[:sma] = sma
1237
+ filtered = prices.select{|row| row[:date] >= 2}
1238
+ # On the full Namo the date-2 SMA averages dates 1 and 2 (15.0); on the
1239
+ # filtered Namo it sees only date 2, so the value differs.
1240
+ _(prices.values(:sma)).must_equal [10.0, 15.0, 20.0]
1241
+ _(filtered.values(:sma)).must_equal [20.0, 25.0]
1242
+ end
1243
+
1244
+ it "is live: appending a row changes the two-arity result on next access" do
1245
+ prices[:sma] = sma
1246
+ _(prices.last[:sma]).must_equal 20.0
1247
+ prices.data << {symbol: 'AAA', date: 4, close: 40.0}
1248
+ _(prices.last[:sma]).must_equal 25.0
1249
+ end
1250
+
1251
+ it "carries a two-arity formula through a set-operator result, windowing over the combined rows" do
1252
+ a = Namo.new([{symbol: 'AAA', date: 1}, {symbol: 'AAA', date: 2}])
1253
+ b = Namo.new([{symbol: 'AAA', date: 3}])
1254
+ a[:peers] = ->(row, namo){namo.count}
1255
+ _((a + b).values(:peers)).must_equal [3, 3, 3]
1256
+ end
1257
+
1258
+ it "carries a merged two-arity formula through a composition result, windowing over the joined rows" do
1259
+ left = Namo.new([{symbol: 'AAA', date: 1}, {symbol: 'AAA', date: 2}])
1260
+ right = Namo.new([{symbol: 'AAA', sector: 'tech'}])
1261
+ left[:peers] = ->(row, namo){namo.count}
1262
+ result = left * right
1263
+ _(result.values(:peers)).must_equal [2, 2]
1264
+ end
1265
+
1266
+ it "resolves a two-arity formula of self inside a composition block" do
1267
+ left = Namo.new([{symbol: 'AAA', date: 1}, {symbol: 'AAA', date: 2}])
1268
+ right = Namo.new([{symbol: 'AAA', sector: 'tech'}])
1269
+ left[:peers] = ->(row, namo){namo.count}
1270
+ seen = nil
1271
+ left.*(right){|row, candidates| seen = row[:peers]; candidates}
1272
+ _(seen).must_equal 2
1273
+ end
1274
+
1275
+ it "lets a one-arity formula reference a two-arity formula by name" do
1276
+ prices[:sma] = sma
1277
+ prices[:double_sma] = ->(row){row[:sma] * 2}
1278
+ _(prices.values(:double_sma)).must_equal [20.0, 30.0, 40.0]
1279
+ end
1280
+
1281
+ it "lets a two-arity formula reference a one-arity formula by name" do
1282
+ prices[:tenth] = ->(row){row[:close] / 10.0}
1283
+ prices[:tenth_plus_count] = ->(row, namo){row[:tenth] + namo.count}
1284
+ _(prices.values(:tenth_plus_count)).must_equal [4.0, 5.0, 6.0]
1285
+ end
1286
+
1287
+ it "returns [] for a two-arity dimension on an empty Namo without invoking the formula" do
1288
+ invoked = false
1289
+ empty = Namo.new([], formulae: {sma: ->(row, namo){invoked = true; 0}})
1290
+ _(empty.values(:sma)).must_equal []
1291
+ _(invoked).must_equal false
1292
+ end
1293
+ end
1294
+
1295
+ describe "#[]= parameterised formulae" do
1296
+ let(:price_data) do
1297
+ [
1298
+ {symbol: 'AAA', date: 1, close: 10.0, volume: 100},
1299
+ {symbol: 'AAA', date: 2, close: 20.0, volume: 200},
1300
+ {symbol: 'AAA', date: 3, close: 30.0, volume: 300},
1301
+ ]
1302
+ end
1303
+
1304
+ let(:prices) do
1305
+ Namo.new(price_data)
1306
+ end
1307
+
1308
+ # A parameterised moving average: the field and the window length arrive at
1309
+ # access time, so one definition serves every column and every period.
1310
+ let(:sma) do
1311
+ proc do |row, namo, field, period|
1312
+ window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}].last(period)
1313
+ window.sum{|r| r[field]} / window.count.to_f
1314
+ end
1315
+ end
1316
+
1317
+ it "resolves with arguments through a yielded Row" do
1318
+ prices[:sma] = sma
1319
+ _(prices.first[:sma, :close, 2]).must_equal 10.0
1320
+ _(prices.last[:sma, :close, 2]).must_equal 25.0
1321
+ end
1322
+
1323
+ it "serves different fields and periods from one definition" do
1324
+ prices[:sma] = sma
1325
+ _(prices.last[:sma, :close, 3]).must_equal 20.0
1326
+ _(prices.last[:sma, :volume, 2]).must_equal 250.0
1327
+ end
1328
+
1329
+ it "resolves inside an Enumerable predicate" do
1330
+ prices[:sma] = sma
1331
+ result = prices.select{|row| row[:sma, :close, 2] > 12.0}
1332
+ _(result).must_be_kind_of Namo
1333
+ _(result.values(:date)).must_equal [2, 3]
1334
+ end
1335
+
1336
+ it "lets a one-arity formula reference a parameterised formula with arguments" do
1337
+ prices[:sma] = sma
1338
+ prices[:rising] = proc{|row| row[:sma, :close, 1] > row[:sma, :close, 3]}
1339
+ _(prices.values(:rising)).must_equal [false, true, true]
1340
+ end
1341
+
1342
+ it "lists a parameterised dimension in dimensions and derived_dimensions" do
1343
+ prices[:sma] = sma
1344
+ _(prices.dimensions).must_equal [:symbol, :date, :close, :volume, :sma]
1345
+ _(prices.derived_dimensions).must_equal [:sma]
1346
+ end
1347
+
1348
+ it "omits a parameterised dimension from the no-arg values, coordinates, and to_h" do
1349
+ prices[:sma] = sma
1350
+ _(prices.values.keys).must_equal [:symbol, :date, :close, :volume]
1351
+ _(prices.coordinates.keys).must_equal [:symbol, :date, :close, :volume]
1352
+ _(prices.to_h.keys).must_equal [:symbol, :date, :close, :volume]
1353
+ end
1354
+
1355
+ it "raises when values is asked for a parameterised dimension by name" do
1356
+ prices[:sma] = sma
1357
+ error = _(proc{prices.values(:sma)}).must_raise ArgumentError
1358
+ _(error.message).must_equal "wrong number of arguments for :sma (given 0, expected 2)"
1359
+ end
1360
+
1361
+ it "raises when coordinates is asked for a parameterised dimension by name" do
1362
+ prices[:sma] = sma
1363
+ _(proc{prices.coordinates(:sma)}).must_raise ArgumentError
1364
+ end
1365
+
1366
+ it "raises when a projection names a parameterised dimension" do
1367
+ prices[:sma] = sma
1368
+ _(proc{prices[:date, :sma]}).must_raise ArgumentError
1369
+ end
1370
+
1371
+ it "raises when a selection selects on a parameterised dimension" do
1372
+ prices[:sma] = sma
1373
+ _(proc{prices[sma: ->(v){v > 12.0}]}).must_raise ArgumentError
1374
+ end
1375
+
1376
+ it "carries a parameterised formula through contraction" do
1377
+ prices[:sma] = sma
1378
+ contracted = prices[-:volume]
1379
+ _(contracted.derived_dimensions).must_equal [:sma]
1380
+ _(contracted.first[:sma, :close, 2]).must_equal 10.0
1381
+ end
1382
+
1383
+ it "carries a parameterised formula through selection, windowing over the filtered rows" do
1384
+ prices[:sma] = sma
1385
+ filtered = prices[date: 2..3]
1386
+ _(filtered.first[:sma, :close, 2]).must_equal 20.0
1387
+ end
1388
+
1389
+ it "materialises through a one-arity wrapper that binds the arguments" do
1390
+ prices[:sma] = sma
1391
+ prices[:sma_close_2] = proc{|row| row[:sma, :close, 2]}
1392
+ _(prices.values(:sma_close_2)).must_equal [10.0, 15.0, 25.0]
1393
+ _(prices[:date, :sma_close_2].values(:sma_close_2)).must_equal [10.0, 15.0, 25.0]
1394
+ end
1395
+
1396
+ it "includes a namo-plus-splat formula in the bulk views, called with no extra arguments" do
1397
+ prices[:flexible] = proc{|row, namo, *rest| rest.empty? ? namo.count : rest.sum}
1398
+ _(prices.values.keys).must_include :flexible
1399
+ _(prices.values(:flexible)).must_equal [3, 3, 3]
1400
+ _(prices.first[:flexible, 1, 2, 4]).must_equal 7
1401
+ end
1402
+
1403
+ it "carries a parameterised formula through a set-operator result" do
1404
+ a = Namo.new(price_data.take(2))
1405
+ b = Namo.new([price_data.last])
1406
+ a[:sma] = sma
1407
+ _((a + b).last[:sma, :close, 2]).must_equal 25.0
1408
+ end
1409
+
1410
+ it "returns [] for a parameterised dimension on an empty Namo without invoking the formula" do
1411
+ invoked = false
1412
+ empty = Namo.new([], formulae: {sma: ->(row, namo, field, period){invoked = true; 0}})
1413
+ _(empty.values(:sma)).must_equal []
1414
+ _(invoked).must_equal false
1415
+ end
1416
+ end
1417
+
1418
+ describe "data/formula exclusivity" do
1419
+ context "projection" do
1420
+ let(:price_data) do
1421
+ [
1422
+ {symbol: 'AAA', date: 1, close: 10.0},
1423
+ {symbol: 'AAA', date: 2, close: 20.0},
1424
+ {symbol: 'AAA', date: 3, close: 30.0},
1425
+ ]
1426
+ end
1427
+
1428
+ let(:prices) do
1429
+ Namo.new(price_data)
1430
+ end
1431
+
1432
+ let(:sma) do
1433
+ ->(row, namo){
1434
+ window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}]
1435
+ window.values(:close).sum / window.count.to_f
1436
+ }
1437
+ end
1438
+
1439
+ it "agrees across all access paths on a materialised dimension" do
1440
+ prices[:sma] = sma
1441
+ projected = prices[:date, :sma]
1442
+ _(projected.values(:sma)).must_equal [10.0, 15.0, 20.0]
1443
+ _(projected.first[:sma]).must_equal projected.values(:sma).first
1444
+ _(projected[sma: ->(v){v > 12.0}].values(:date)).must_equal [2, 3]
1445
+ end
1446
+
1447
+ it "lists a materialised dimension as data, not derived, exactly once" do
1448
+ prices[:sma] = sma
1449
+ projected = prices[:date, :sma]
1450
+ _(projected.data_dimensions).must_include :sma
1451
+ _(projected.derived_dimensions).wont_include :sma
1452
+ _(projected.dimensions.count(:sma)).must_equal 1
1453
+ end
1454
+
1455
+ it "carries a dependent formula not named in the projection, resolving off the materialised column" do
1456
+ prices[:sma] = sma
1457
+ prices[:double_sma] = ->(row){row[:sma] * 2}
1458
+ projected = prices[:date, :sma]
1459
+ _(projected.derived_dimensions).must_equal [:double_sma]
1460
+ _(projected.values(:double_sma)).must_equal [20.0, 30.0, 40.0]
1461
+ end
1462
+
1463
+ it "carries an omitted formula live, recomputing from the result's own rows" do
1464
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1465
+ projected = sales[:price, :quantity]
1466
+ _(projected.derived_dimensions).must_equal [:revenue]
1467
+ _(projected.values(:revenue)).must_equal [1000.0, 1500.0, 1000.0, 1500.0]
1468
+ projected.data.first[:quantity] = 200
1469
+ _(projected.values(:revenue).first).must_equal 2000.0
1470
+ end
1471
+
1472
+ it "breaks on access when a carried formula's inputs were dropped (caveat emptor)" do
1473
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1474
+ projected = sales[:product]
1475
+ _(projected.derived_dimensions).must_equal [:revenue]
1476
+ _ { projected.values(:revenue) }.must_raise NoMethodError
1477
+ end
1478
+
1479
+ it "materialises a two-arity formula windowed over the yielding Namo" do
1480
+ prices[:sma] = sma
1481
+ _(prices[:date, :sma].values(:sma)).must_equal [10.0, 15.0, 20.0]
1482
+ end
1483
+
1484
+ it "windows a two-arity materialisation over a same-call selection" do
1485
+ prices[:sma] = sma
1486
+ projected = prices[:date, :sma, date: 2..3]
1487
+ _(projected.values(:sma)).must_equal [20.0, 25.0]
1488
+ end
1489
+
1490
+ it "carries all formulae through a selection-only call" do
1491
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1492
+ result = sales[price: ..15.0]
1493
+ _(result.derived_dimensions).must_equal [:revenue]
1494
+ _(result.values(:revenue)).must_equal [1000.0, 1500.0]
1495
+ end
1496
+
1497
+ it "carries all formulae through contraction" do
1498
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1499
+ result = sales[-:quarter]
1500
+ _(result.derived_dimensions).must_equal [:revenue]
1501
+ _(result.values(:revenue)).must_equal [1000.0, 1500.0, 1000.0, 1500.0]
1502
+ end
1503
+
1504
+ it "returns pure materialised values and empty formulae when only derived names are projected" do
1505
+ prices[:sma] = sma
1506
+ projected = prices[:sma]
1507
+ _(projected.to_a).must_equal [{sma: 10.0}, {sma: 15.0}, {sma: 20.0}]
1508
+ _(projected.formulae).must_be_empty
1509
+ end
1510
+
1511
+ it "returns an instance of self's class" do
1512
+ subclass = Class.new(Namo)
1513
+ namo = subclass.new([{x: 1}])
1514
+ namo[:double] = ->(row){row[:x] * 2}
1515
+ _(namo[:double].class).must_equal subclass
1516
+ end
1517
+ end
1518
+
1519
+ context "composition" do
1520
+ let(:audited) do
1521
+ Namo.new([
1522
+ {symbol: 'BHP', margin: 0.3},
1523
+ {symbol: 'RIO', margin: 0.25}
1524
+ ])
1525
+ end
1526
+
1527
+ let(:modelled) do
1528
+ namo = Namo.new([
1529
+ {symbol: 'BHP', price: 10.0, cost: 6.0},
1530
+ {symbol: 'RIO', price: 20.0, cost: 16.0}
1531
+ ])
1532
+ namo[:margin] = proc{|r| (r[:price] - r[:cost]) / r[:price]}
1533
+ namo
1534
+ end
1535
+
1536
+ let(:audited_orders) do
1537
+ Namo.new([{order: 'A', margin: 0.3}])
1538
+ end
1539
+
1540
+ let(:modelled_tiers) do
1541
+ namo = Namo.new([{tier: 'light', price: 10.0, cost: 6.0}])
1542
+ namo[:margin] = proc{|r| (r[:price] - r[:cost]) / r[:price]}
1543
+ namo
1544
+ end
1545
+
1546
+ it "raises on * when self's data dimension is other's derived dimension" do
1547
+ _ { audited * modelled }.must_raise ArgumentError
1548
+ end
1549
+
1550
+ it "raises on * when self's derived dimension is other's data dimension" do
1551
+ _ { modelled * audited }.must_raise ArgumentError
1552
+ end
1553
+
1554
+ it "raises on ** when self's data dimension is other's derived dimension" do
1555
+ _ { audited_orders ** modelled_tiers }.must_raise ArgumentError
1556
+ end
1557
+
1558
+ it "raises on ** when self's derived dimension is other's data dimension" do
1559
+ _ { modelled_tiers ** audited_orders }.must_raise ArgumentError
1560
+ end
1561
+
1562
+ it "raises in the block forms of both operators" do
1563
+ _ { audited.*(modelled){|row, candidates| candidates} }.must_raise ArgumentError
1564
+ _ { audited_orders.**(modelled_tiers){|row, candidates| candidates} }.must_raise ArgumentError
1565
+ end
1566
+
1567
+ it "names the colliding dimensions in the message" do
1568
+ err = _ { audited * modelled }.must_raise ArgumentError
1569
+ _(err.message).must_match(/name collision between data and formulae/)
1570
+ _(err.message).must_include ':margin'
1571
+ end
1572
+
1573
+ it "does not raise on a formula-vs-formula collision — left wins" do
1574
+ left = Namo.new([{symbol: 'BHP', close: 42.5}])
1575
+ right = Namo.new([{symbol: 'BHP', pe: 14.5}])
1576
+ left[:margin] = proc{|r| :left}
1577
+ right[:margin] = proc{|r| :right}
1578
+ _((left * right).values(:margin)).must_equal [:left]
1579
+ end
1580
+
1581
+ it "composes after explicit resolution by contraction" do
1582
+ result = audited[-:margin] * modelled
1583
+ _(result.values(:margin)).must_equal [0.4, 0.2]
1584
+ end
1585
+ end
1586
+ end
1587
+
1588
+ describe "#each" do
1589
+ it "yields Row objects" do
1590
+ rows = []
1591
+ sales.each{|row| rows << row}
1592
+ _(rows.first).must_be_kind_of Namo::Row
1593
+ _(rows.length).must_equal 4
1594
+ end
1595
+
1596
+ it "yields rows with access to data" do
1597
+ products = sales.map{|row| row[:product]}
1598
+ _(products).must_equal ['Widget', 'Widget', 'Gadget', 'Gadget']
1599
+ end
1600
+
1601
+ it "yields rows with access to formulae" do
1602
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1603
+ revenues = sales.map{|row| row[:revenue]}
1604
+ _(revenues).must_equal [1000.0, 1500.0, 1000.0, 1500.0]
1605
+ end
1606
+
1607
+ it "returns an enumerator without a block" do
1608
+ _(sales.each).must_be_kind_of Enumerator
1609
+ end
1610
+ end
1611
+
1612
+ describe "Enumerable" do
1613
+ it "supports reduce" do
1614
+ total_quantity = sales.reduce(0){|sum, row| sum + row[:quantity]}
1615
+ _(total_quantity).must_equal 350
1616
+ end
1617
+
1618
+ it "supports reduce with selection" do
1619
+ widget_quantity = sales[product: 'Widget'].reduce(0){|sum, row| sum + row[:quantity]}
1620
+ _(widget_quantity).must_equal 250
1621
+ end
1622
+
1623
+ it "supports reduce with formulae" do
1624
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1625
+ total_revenue = sales.reduce(0){|sum, row| sum + row[:revenue]}
1626
+ _(total_revenue).must_equal 5000.0
1627
+ end
1628
+
1629
+ it "supports reduce with selection and formulae" do
1630
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1631
+ widget_revenue = sales[product: 'Widget'].reduce(0){|sum, row| sum + row[:revenue]}
1632
+ _(widget_revenue).must_equal 2500.0
1633
+ end
1634
+
1635
+ it "supports min_by" do
1636
+ cheapest = sales.min_by{|row| row[:price]}
1637
+ _(cheapest[:product]).must_equal 'Widget'
1638
+ end
1639
+
1640
+ it "supports flat_map" do
1641
+ prices = sales.flat_map{|row| [row[:price]]}
1642
+ _(prices).must_equal [10.0, 10.0, 25.0, 25.0]
1643
+ end
1644
+ end
1645
+
1646
+ describe "#select" do
1647
+ it "returns a Namo of matching rows" do
1648
+ result = sales.select{|row| row[:price] < 20.0}
1649
+ _(result).must_be_kind_of Namo
1650
+ _(result.to_a).must_equal [
1651
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
1652
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150}
1653
+ ]
1654
+ end
1655
+
1656
+ it "selects using formula references in the block" do
1657
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1658
+ result = sales.select{|row| row[:revenue] >= 1500.0}
1659
+ _(result.to_a).must_equal [
1660
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150, revenue: 1500.0},
1661
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60, revenue: 1500.0}
1662
+ ]
1663
+ end
1664
+
1665
+ it "preserves formulae through to the returned Namo" do
1666
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1667
+ result = sales.select{|row| row[:price] < 20.0}
1668
+ _(result.values(:revenue)).must_equal [1000.0, 1500.0]
1669
+ end
1670
+
1671
+ it "returns an empty Namo when nothing matches" do
1672
+ result = sales.select{|row| row[:price] > 1000.0}
1673
+ _(result).must_be_kind_of Namo
1674
+ _(result.to_a).must_equal []
1675
+ end
1676
+
1677
+ it "returns an instance of self's class" do
1678
+ subclass = Class.new(Namo)
1679
+ result = subclass.new(sample_data).select{|row| row[:price] < 20.0}
1680
+ _(result.class).must_equal subclass
1681
+ end
1682
+
1683
+ it "is aliased as filter, returning a Namo" do
1684
+ result = sales.filter{|row| row[:price] < 20.0}
1685
+ _(result).must_be_kind_of Namo
1686
+ _(result.values(:product)).must_equal ['Widget', 'Widget']
1687
+ end
1688
+
1689
+ it "is aliased as find_all, returning a Namo" do
1690
+ result = sales.find_all{|row| row[:price] < 20.0}
1691
+ _(result).must_be_kind_of Namo
1692
+ _(result.values(:product)).must_equal ['Widget', 'Widget']
1693
+ end
1694
+ end
1695
+
1696
+ describe "#reject" do
1697
+ it "returns the complement of select" do
1698
+ result = sales.reject{|row| row[:price] < 20.0}
1699
+ _(result).must_be_kind_of Namo
1700
+ _(result.to_a).must_equal [
1701
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
1702
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
1703
+ ]
1704
+ end
1705
+
1706
+ it "together with select sums to the original" do
1707
+ selected = sales.select{|row| row[:price] < 20.0}
1708
+ rejected = sales.reject{|row| row[:price] < 20.0}
1709
+ _((selected.to_a + rejected.to_a).length).must_equal sample_data.length
1710
+ end
1711
+
1712
+ it "preserves formulae through to the returned Namo" do
1713
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1714
+ result = sales.reject{|row| row[:price] < 20.0}
1715
+ _(result.values(:revenue)).must_equal [1000.0, 1500.0]
1716
+ end
1717
+
1718
+ it "returns an instance of self's class" do
1719
+ subclass = Class.new(Namo)
1720
+ result = subclass.new(sample_data).reject{|row| row[:price] < 20.0}
1721
+ _(result.class).must_equal subclass
1722
+ end
1723
+ end
1724
+
1725
+ describe "#sort_by" do
1726
+ it "returns rows in the specified order" do
1727
+ result = sales.sort_by{|row| row[:quantity]}
1728
+ _(result).must_be_kind_of Namo
1729
+ _(result.values(:quantity)).must_equal [40, 60, 100, 150]
1730
+ end
1731
+
1732
+ it "sorts using formula references in the block" do
1733
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1734
+ result = sales.sort_by{|row| row[:revenue]}
1735
+ _(result.values(:revenue)).must_equal [1000.0, 1000.0, 1500.0, 1500.0]
1736
+ end
1737
+
1738
+ it "returns an instance of self's class" do
1739
+ subclass = Class.new(Namo)
1740
+ result = subclass.new(sample_data).sort_by{|row| row[:quantity]}
1741
+ _(result.class).must_equal subclass
1742
+ end
1743
+ end
1744
+
1745
+ describe "#first" do
1746
+ it "with an argument returns a Namo of the first n rows" do
1747
+ result = sales.first(2)
1748
+ _(result).must_be_kind_of Namo
1749
+ _(result.to_a).must_equal [
1750
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
1751
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150}
1752
+ ]
1753
+ end
1754
+
1755
+ it "with an argument of 0 returns an empty Namo" do
1756
+ result = sales.first(0)
1757
+ _(result).must_be_kind_of Namo
1758
+ _(result.to_a).must_equal []
1759
+ end
1760
+
1761
+ it "without an argument returns a Row" do
1762
+ result = sales.first
1763
+ _(result).must_be_kind_of Namo::Row
1764
+ _(result[:product]).must_equal 'Widget'
1765
+ end
1766
+
1767
+ it "without an argument on an empty Namo returns nil" do
1768
+ _(Namo.new.first).must_be_nil
1769
+ end
1770
+
1771
+ it "preserves formulae through to the returned Namo" do
1772
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1773
+ _(sales.first(2).values(:revenue)).must_equal [1000.0, 1500.0]
1774
+ end
1775
+
1776
+ it "returns an instance of self's class with an argument" do
1777
+ subclass = Class.new(Namo)
1778
+ _(subclass.new(sample_data).first(2).class).must_equal subclass
1779
+ end
1780
+ end
1781
+
1782
+ describe "#last" do
1783
+ it "with an argument returns a Namo of the last n rows" do
1784
+ result = sales.last(2)
1785
+ _(result).must_be_kind_of Namo
1786
+ _(result.to_a).must_equal [
1787
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
1788
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
1789
+ ]
1790
+ end
1791
+
1792
+ it "with an argument of 0 returns an empty Namo" do
1793
+ result = sales.last(0)
1794
+ _(result).must_be_kind_of Namo
1795
+ _(result.to_a).must_equal []
1796
+ end
1797
+
1798
+ it "without an argument returns a Row" do
1799
+ result = sales.last
1800
+ _(result).must_be_kind_of Namo::Row
1801
+ _(result[:product]).must_equal 'Gadget'
1802
+ _(result[:quarter]).must_equal 'Q2'
1803
+ end
1804
+
1805
+ it "without an argument on an empty Namo returns nil" do
1806
+ _(Namo.new.last).must_be_nil
1807
+ end
1808
+
1809
+ it "preserves formulae through to the returned Namo" do
1810
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1811
+ _(sales.last(2).values(:revenue)).must_equal [1000.0, 1500.0]
1812
+ end
1813
+
1814
+ it "returns an instance of self's class with an argument" do
1815
+ subclass = Class.new(Namo)
1816
+ _(subclass.new(sample_data).last(2).class).must_equal subclass
1817
+ end
1818
+ end
1819
+
1820
+ describe "#take" do
1821
+ it "returns a Namo of the first n rows" do
1822
+ result = sales.take(2)
1823
+ _(result).must_be_kind_of Namo
1824
+ _(result.values(:quantity)).must_equal [100, 150]
1825
+ end
1826
+
1827
+ it "returns an empty Namo for n of 0" do
1828
+ _(sales.take(0).to_a).must_equal []
1829
+ end
1830
+
1831
+ it "returns all rows when n exceeds the length" do
1832
+ _(sales.take(10).to_a).must_equal sample_data
1833
+ end
1834
+
1835
+ it "returns an instance of self's class" do
1836
+ subclass = Class.new(Namo)
1837
+ _(subclass.new(sample_data).take(2).class).must_equal subclass
1838
+ end
1839
+ end
1840
+
1841
+ describe "#drop" do
1842
+ it "returns a Namo of all rows past the first n" do
1843
+ result = sales.drop(2)
1844
+ _(result).must_be_kind_of Namo
1845
+ _(result.values(:quantity)).must_equal [40, 60]
1846
+ end
1847
+
1848
+ it "returns all rows for n of 0" do
1849
+ _(sales.drop(0).to_a).must_equal sample_data
1850
+ end
1851
+
1852
+ it "returns an empty Namo when n exceeds the length" do
1853
+ _(sales.drop(10).to_a).must_equal []
1854
+ end
1855
+
1856
+ it "returns an instance of self's class" do
1857
+ subclass = Class.new(Namo)
1858
+ _(subclass.new(sample_data).drop(2).class).must_equal subclass
1859
+ end
1860
+ end
1861
+
1862
+ describe "#take_while" do
1863
+ it "returns a Namo of leading rows while the predicate holds" do
1864
+ result = sales.take_while{|row| row[:price] < 20.0}
1865
+ _(result).must_be_kind_of Namo
1866
+ _(result.values(:product)).must_equal ['Widget', 'Widget']
1867
+ end
1868
+
1869
+ it "evaluates the predicate against formula references" do
1870
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1871
+ result = sales.take_while{|row| row[:revenue] < 1500.0}
1872
+ _(result.values(:revenue)).must_equal [1000.0]
1873
+ end
1874
+
1875
+ it "returns an instance of self's class" do
1876
+ subclass = Class.new(Namo)
1877
+ _(subclass.new(sample_data).take_while{|row| row[:price] < 20.0}.class).must_equal subclass
1878
+ end
1879
+ end
1880
+
1881
+ describe "#drop_while" do
1882
+ it "returns a Namo of rows from the first predicate failure" do
1883
+ result = sales.drop_while{|row| row[:price] < 20.0}
1884
+ _(result).must_be_kind_of Namo
1885
+ _(result.values(:product)).must_equal ['Gadget', 'Gadget']
1886
+ end
1887
+
1888
+ it "evaluates the predicate against formula references" do
1889
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1890
+ result = sales.drop_while{|row| row[:revenue] < 1500.0}
1891
+ _(result.values(:revenue)).must_equal [1500.0, 1000.0, 1500.0]
1892
+ end
1893
+
1894
+ it "returns an instance of self's class" do
1895
+ subclass = Class.new(Namo)
1896
+ _(subclass.new(sample_data).drop_while{|row| row[:price] < 20.0}.class).must_equal subclass
1897
+ end
1898
+ end
1899
+
1900
+ describe "#uniq" do
1901
+ let(:dup_data) do
1902
+ [
1903
+ {product: 'Widget', quarter: 'Q1'},
1904
+ {product: 'Widget', quarter: 'Q1'},
1905
+ {product: 'Gadget', quarter: 'Q1'},
1906
+ {product: 'Widget', quarter: 'Q2'}
1907
+ ]
1908
+ end
1909
+
1910
+ it "without a block dedupes rows on full-row equality" do
1911
+ result = Namo.new(dup_data).uniq
1912
+ _(result).must_be_kind_of Namo
1913
+ _(result.to_a).must_equal [
1914
+ {product: 'Widget', quarter: 'Q1'},
1915
+ {product: 'Gadget', quarter: 'Q1'},
1916
+ {product: 'Widget', quarter: 'Q2'}
1917
+ ]
1918
+ end
1919
+
1920
+ it "distinguishes numeric types, matching Row#eql? semantics" do
1921
+ result = Namo.new([{n: 1}, {n: 1.0}]).uniq
1922
+ _(result.to_a).must_equal [{n: 1}, {n: 1.0}]
1923
+ end
1924
+
1925
+ it "with a block dedupes on the block's return value" do
1926
+ result = Namo.new(dup_data).uniq{|row| row[:product]}
1927
+ _(result.to_a).must_equal [
1928
+ {product: 'Widget', quarter: 'Q1'},
1929
+ {product: 'Gadget', quarter: 'Q1'}
1930
+ ]
1931
+ end
1932
+
1933
+ it "preserves formulae through to the returned Namo" do
1934
+ namo = Namo.new(dup_data)
1935
+ namo[:label] = proc{|r| "#{r[:product]}-#{r[:quarter]}"}
1936
+ result = namo.uniq
1937
+ _(result.values(:label)).must_equal ['Widget-Q1', 'Gadget-Q1', 'Widget-Q2']
1938
+ end
1939
+
1940
+ it "returns an instance of self's class" do
1941
+ subclass = Class.new(Namo)
1942
+ _(subclass.new(dup_data).uniq.class).must_equal subclass
1943
+ end
1944
+ end
1945
+
1946
+ describe "#partition" do
1947
+ it "returns a two-element Array of Namos" do
1948
+ result = sales.partition{|row| row[:price] < 20.0}
1949
+ _(result).must_be_kind_of Array
1950
+ _(result.length).must_equal 2
1951
+ _(result[0]).must_be_kind_of Namo
1952
+ _(result[1]).must_be_kind_of Namo
1953
+ end
1954
+
1955
+ it "splits into matches and non-matches summing to the original" do
1956
+ matches, non_matches = sales.partition{|row| row[:price] < 20.0}
1957
+ _(matches.to_a).must_equal [
1958
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
1959
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150}
1960
+ ]
1961
+ _(non_matches.to_a).must_equal [
1962
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
1963
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
1964
+ ]
1965
+ _((matches.to_a + non_matches.to_a).length).must_equal sample_data.length
1966
+ end
1967
+
1968
+ it "partitions using formula references in the block" do
1969
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1970
+ matches, non_matches = sales.partition{|row| row[:revenue] >= 1500.0}
1971
+ _(matches.values(:revenue)).must_equal [1500.0, 1500.0]
1972
+ _(non_matches.values(:revenue)).must_equal [1000.0, 1000.0]
1973
+ end
1974
+
1975
+ it "preserves formulae through to both returned Namos" do
1976
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1977
+ matches, non_matches = sales.partition{|row| row[:price] < 20.0}
1978
+ _(matches.values(:revenue)).must_equal [1000.0, 1500.0]
1979
+ _(non_matches.values(:revenue)).must_equal [1000.0, 1500.0]
1980
+ end
1981
+
1982
+ it "returns instances of self's class" do
1983
+ subclass = Class.new(Namo)
1984
+ matches, non_matches = subclass.new(sample_data).partition{|row| row[:price] < 20.0}
1985
+ _(matches.class).must_equal subclass
1986
+ _(non_matches.class).must_equal subclass
1987
+ end
1988
+ end
1989
+
1990
+ describe "subset methods on an empty Namo" do
1991
+ let(:empty) { Namo.new }
1992
+
1993
+ it "select returns an empty Namo" do
1994
+ _(empty.select{|row| true}.to_a).must_equal []
1995
+ end
1996
+
1997
+ it "reject returns an empty Namo" do
1998
+ _(empty.reject{|row| true}.to_a).must_equal []
1999
+ end
2000
+
2001
+ it "sort_by returns an empty Namo" do
2002
+ _(empty.sort_by{|row| row[:x]}.to_a).must_equal []
2003
+ end
2004
+
2005
+ it "first(n) returns an empty Namo" do
2006
+ _(empty.first(2).to_a).must_equal []
2007
+ end
2008
+
2009
+ it "last(n) returns an empty Namo" do
2010
+ _(empty.last(2).to_a).must_equal []
2011
+ end
2012
+
2013
+ it "take and drop return empty Namos" do
2014
+ _(empty.take(2).to_a).must_equal []
2015
+ _(empty.drop(2).to_a).must_equal []
2016
+ end
2017
+
2018
+ it "take_while and drop_while return empty Namos" do
2019
+ _(empty.take_while{|row| true}.to_a).must_equal []
2020
+ _(empty.drop_while{|row| true}.to_a).must_equal []
2021
+ end
2022
+
2023
+ it "uniq returns an empty Namo" do
2024
+ _(empty.uniq.to_a).must_equal []
2025
+ end
2026
+
2027
+ it "partition returns two empty Namos" do
2028
+ matches, non_matches = empty.partition{|row| true}
2029
+ _(matches.to_a).must_equal []
2030
+ _(non_matches.to_a).must_equal []
2031
+ end
2032
+ end
2033
+
2034
+ describe "unchanged Enumerable methods" do
2035
+ it "map still returns an Array" do
2036
+ _(sales.map{|row| row[:product]}).must_be_kind_of Array
2037
+ end
2038
+
2039
+ it "flat_map still returns an Array" do
2040
+ _(sales.flat_map{|row| [row[:price]]}).must_be_kind_of Array
2041
+ end
2042
+
2043
+ it "reduce still returns a scalar" do
2044
+ _(sales.reduce(0){|sum, row| sum + row[:quantity]}).must_equal 350
2045
+ end
2046
+ end
2047
+
2048
+ describe "Namo::Enumerable module" do
2049
+ it "is a Module supplying the subset methods" do
2050
+ _(Namo::Enumerable).must_be_kind_of Module
2051
+ end
2052
+
2053
+ it "is included in Namo, transitively including stdlib Enumerable" do
2054
+ _(Namo.include?(Namo::Enumerable)).must_equal true
2055
+ _(Namo.include?(Enumerable)).must_equal true
2056
+ end
2057
+
2058
+ it "sits above stdlib Enumerable so its overrides win" do
2059
+ ancestors = Namo.ancestors
2060
+ _(ancestors.index(Namo::Enumerable) < ancestors.index(::Enumerable)).must_equal true
2061
+ end
2062
+ end
2063
+
2064
+ describe "#+" do
2065
+ let(:more_data) do
2066
+ [
2067
+ {product: 'Widget', quarter: 'Q3', price: 10.0, quantity: 200},
2068
+ {product: 'Gadget', quarter: 'Q3', price: 25.0, quantity: 80}
2069
+ ]
2070
+ end
2071
+
2072
+ let(:more_sales) do
2073
+ Namo.new(more_data)
2074
+ end
2075
+
2076
+ it "concatenates rows" do
2077
+ result = sales + more_sales
2078
+ _(result.to_a.count).must_equal 6
2079
+ _(result.to_a).must_equal(sample_data + more_data)
2080
+ end
2081
+
2082
+ it "preserves dimensions" do
2083
+ result = sales + more_sales
2084
+ _(result.dimensions).must_equal [:product, :quarter, :price, :quantity]
2085
+ end
2086
+
2087
+ it "carries formulae through from self" do
2088
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
2089
+ result = sales + more_sales
2090
+ _(result.map{|row| row[:revenue]}).must_equal [1000.0, 1500.0, 1000.0, 1500.0, 2000.0, 2000.0]
2091
+ end
2092
+
2093
+ it "merges formulae from other" do
2094
+ more_sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
2095
+ result = sales + more_sales
2096
+ _(result.map{|row| row[:revenue]}).must_equal [1000.0, 1500.0, 1000.0, 1500.0, 2000.0, 2000.0]
2097
+ end
2098
+
2099
+ it "prefers self's formulae on conflict" do
2100
+ sales[:label] = proc{|r| "self: #{r[:product]}"}
2101
+ more_sales[:label] = proc{|r| "other: #{r[:product]}"}
2102
+ result = sales + more_sales
2103
+ _(result.map{|row| row[:label]}).must_equal [
2104
+ 'self: Widget', 'self: Widget', 'self: Gadget', 'self: Gadget',
2105
+ 'self: Widget', 'self: Gadget'
2106
+ ]
2107
+ end
2108
+
2109
+ it "raises when dimensions differ" do
2110
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
2111
+ _ { sales + other }.must_raise ArgumentError
2112
+ end
2113
+ end
2114
+
2115
+ describe "#-" do
2116
+ let(:to_remove) do
2117
+ Namo.new([
2118
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
2119
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
2120
+ ])
2121
+ end
2122
+
2123
+ it "removes matching rows" do
2124
+ result = sales - to_remove
2125
+ _(result.to_a.count).must_equal 2
2126
+ _(result.to_a).must_equal [
2127
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
2128
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40}
2129
+ ]
2130
+ end
2131
+
2132
+ it "preserves non-matching rows" do
2133
+ other = Namo.new([{product: 'Thingo', quarter: 'Q4', price: 99.0, quantity: 1}])
2134
+ result = sales - other
2135
+ _(result.to_a).must_equal sample_data
2136
+ end
2137
+
2138
+ it "carries formulae through from self" do
2139
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
2140
+ result = sales - to_remove
2141
+ _(result.map{|row| row[:revenue]}).must_equal [1500.0, 1000.0]
2142
+ end
2143
+
2144
+ it "raises when dimensions differ" do
2145
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
2146
+ _ { sales - other }.must_raise ArgumentError
2147
+ end
2148
+ end
2149
+
2150
+ describe "#&" do
2151
+ let(:other) do
2152
+ Namo.new([
2153
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
2154
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60},
2155
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
2156
+ ])
2157
+ end
2158
+
2159
+ it "returns rows present in both" do
2160
+ result = sales & other
2161
+ _(result.to_a).must_equal [
2162
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
2163
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
2164
+ ]
2165
+ end
2166
+
2167
+ it "returns empty when nothing overlaps" do
2168
+ other = Namo.new([{product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}])
2169
+ result = sales & other
2170
+ _(result.to_a).must_equal []
2171
+ end
2172
+
2173
+ it "preserves dimensions" do
2174
+ result = sales & other
2175
+ _(result.dimensions).must_equal [:product, :quarter, :price, :quantity]
2176
+ end
2177
+
2178
+ it "carries formulae through from self" do
2179
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
2180
+ result = sales & other
2181
+ _(result.map{|row| row[:revenue]}).must_equal [1000.0, 1500.0]
2182
+ end
2183
+
2184
+ it "raises when dimensions differ" do
2185
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
2186
+ _ { sales & other }.must_raise ArgumentError
2187
+ end
2188
+ end
2189
+
2190
+ describe "#|" do
2191
+ let(:other) do
2192
+ Namo.new([
2193
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
2194
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
2195
+ ])
2196
+ end
2197
+
2198
+ it "returns all rows deduplicated" do
2199
+ result = sales | other
2200
+ _(result.to_a).must_equal [
2201
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
2202
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
2203
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
2204
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60},
2205
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
2206
+ ]
2207
+ end
2208
+
2209
+ it "preserves dimensions" do
2210
+ result = sales | other
2211
+ _(result.dimensions).must_equal [:product, :quarter, :price, :quantity]
2212
+ end
2213
+
2214
+ it "merges formulae from other" do
2215
+ other[:revenue] = proc{|r| r[:price] * r[:quantity]}
2216
+ result = sales | other
2217
+ _(result.map{|row| row[:revenue]}).must_equal [1000.0, 1500.0, 1000.0, 1500.0, 50.0]
2218
+ end
2219
+
2220
+ it "prefers self's formulae on conflict" do
2221
+ sales[:label] = proc{|r| "self: #{r[:product]}"}
2222
+ other[:label] = proc{|r| "other: #{r[:product]}"}
2223
+ result = sales | other
2224
+ _(result.map{|row| row[:label]}).must_equal [
2225
+ 'self: Widget', 'self: Widget', 'self: Gadget', 'self: Gadget', 'self: Thingo'
2226
+ ]
2227
+ end
2228
+
2229
+ it "raises when dimensions differ" do
2230
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
2231
+ _ { sales | other }.must_raise ArgumentError
2232
+ end
2233
+ end
2234
+
2235
+ describe "#^" do
2236
+ let(:other) do
2237
+ Namo.new([
2238
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
2239
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60},
2240
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
2241
+ ])
2242
+ end
2243
+
2244
+ it "returns rows in one but not both" do
2245
+ result = sales ^ other
2246
+ _(result.to_a).must_equal [
2247
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
2248
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
2249
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
2250
+ ]
2251
+ end
2252
+
2253
+ it "returns empty when both are identical" do
2254
+ result = sales ^ sales
2255
+ _(result.to_a).must_equal []
2256
+ end
2257
+
2258
+ it "returns all rows when nothing overlaps" do
2259
+ other = Namo.new([{product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}])
2260
+ result = sales ^ other
2261
+ _(result.to_a.count).must_equal 5
2262
+ end
2263
+
2264
+ it "merges formulae with self winning on conflict" do
2265
+ sales[:label] = proc{|r| "self: #{r[:product]}"}
2266
+ other[:label] = proc{|r| "other: #{r[:product]}"}
2267
+ result = sales ^ other
2268
+ _(result.map{|row| row[:label]}).must_equal [
2269
+ 'self: Widget', 'self: Gadget', 'self: Thingo'
2270
+ ]
2271
+ end
2272
+
2273
+ it "raises when dimensions differ" do
2274
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
2275
+ _ { sales ^ other }.must_raise ArgumentError
2276
+ end
2277
+ end
2278
+
2279
+ describe "#*" do
2280
+ let(:ohlcv) do
2281
+ Namo.new([
2282
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5},
2283
+ {symbol: 'RIO', date: '2025-01-01', close: 118.3}
2284
+ ])
2285
+ end
2286
+
2287
+ let(:fundamentals) do
2288
+ Namo.new([
2289
+ {symbol: 'BHP', pe: 14.5},
2290
+ {symbol: 'RIO', pe: 9.2}
2291
+ ])
2292
+ end
2293
+
2294
+ it "joins on a single shared dimension" do
2295
+ result = ohlcv * fundamentals
2296
+ _(result.to_a).must_equal [
2297
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5, pe: 14.5},
2298
+ {symbol: 'RIO', date: '2025-01-01', close: 118.3, pe: 9.2}
2299
+ ]
2300
+ end
2301
+
2302
+ it "joins on multiple shared dimensions" do
2303
+ a = Namo.new([
2304
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5},
2305
+ {symbol: 'BHP', date: '2025-01-02', close: 43.0}
2306
+ ])
2307
+ b = Namo.new([
2308
+ {symbol: 'BHP', date: '2025-01-01', volume: 1000},
2309
+ {symbol: 'BHP', date: '2025-01-02', volume: 1500}
2310
+ ])
2311
+ result = a * b
2312
+ _(result.to_a).must_equal [
2313
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5, volume: 1000},
2314
+ {symbol: 'BHP', date: '2025-01-02', close: 43.0, volume: 1500}
2315
+ ]
2316
+ end
2317
+
2318
+ it "preserves non-shared dimensions from both sides" do
2319
+ result = ohlcv * fundamentals
2320
+ _(result.dimensions).must_equal [:symbol, :date, :close, :pe]
2321
+ end
2322
+
2323
+ it "drops unmatched rows from both sides (inner-join symmetry)" do
2324
+ left = Namo.new([
2325
+ {symbol: 'BHP', close: 42.5},
2326
+ {symbol: 'CBA', close: 100.0}
2327
+ ])
2328
+ right = Namo.new([
2329
+ {symbol: 'BHP', pe: 14.5},
2330
+ {symbol: 'RIO', pe: 9.2}
2331
+ ])
2332
+ result = left * right
2333
+ _(result.to_a).must_equal [{symbol: 'BHP', close: 42.5, pe: 14.5}]
2334
+ end
2335
+
2336
+ it "produces multiplicative duplicates when inputs have duplicates on shared dimensions" do
2337
+ left = Namo.new([
2338
+ {symbol: 'BHP', close: 42.5},
2339
+ {symbol: 'BHP', close: 43.0}
2340
+ ])
2341
+ right = Namo.new([
2342
+ {symbol: 'BHP', pe: 14.5},
2343
+ {symbol: 'BHP', pe: 14.7}
2344
+ ])
2345
+ result = left * right
2346
+ _(result.to_a.length).must_equal 4
2347
+ _(result.to_a).must_equal [
2348
+ {symbol: 'BHP', close: 42.5, pe: 14.5},
2349
+ {symbol: 'BHP', close: 42.5, pe: 14.7},
2350
+ {symbol: 'BHP', close: 43.0, pe: 14.5},
2351
+ {symbol: 'BHP', close: 43.0, pe: 14.7}
2352
+ ]
2353
+ end
2354
+
2355
+ it "carries formulae through from self" do
2356
+ ohlcv[:label] = proc{|r| "#{r[:symbol]}-self"}
2357
+ result = ohlcv * fundamentals
2358
+ _(result.map{|row| row[:label]}).must_equal ['BHP-self', 'RIO-self']
2359
+ end
2360
+
2361
+ it "merges formulae from other" do
2362
+ fundamentals[:flag] = proc{|r| "pe=#{r[:pe]}"}
2363
+ result = ohlcv * fundamentals
2364
+ _(result.map{|row| row[:flag]}).must_equal ['pe=14.5', 'pe=9.2']
2365
+ end
2366
+
2367
+ it "prefers self's formulae on conflict" do
2368
+ ohlcv[:label] = proc{|r| "self: #{r[:symbol]}"}
2369
+ fundamentals[:label] = proc{|r| "other: #{r[:symbol]}"}
2370
+ result = ohlcv * fundamentals
2371
+ _(result.map{|row| row[:label]}).must_equal ['self: BHP', 'self: RIO']
2372
+ end
2373
+
2374
+ it "raises ArgumentError when there are no shared dimensions" do
2375
+ a = Namo.new([{symbol: 'BHP'}])
2376
+ b = Namo.new([{quarter: 'Q1'}])
2377
+ err = _ { a * b }.must_raise ArgumentError
2378
+ _(err.message).must_match(/no shared dimensions, need to have shared dimensions/)
2379
+ end
2380
+
2381
+ it "raises TypeError on a non-Namo operand" do
2382
+ _ { ohlcv * [{symbol: 'BHP'}] }.must_raise TypeError
2383
+ end
2384
+
2385
+ it "returns an instance of self's class" do
2386
+ subclass = Class.new(Namo)
2387
+ a = subclass.new([{symbol: 'BHP', close: 42.5}])
2388
+ b = Namo.new([{symbol: 'BHP', pe: 14.5}])
2389
+ _((a * b).class).must_equal subclass
2390
+ end
2391
+
2392
+ context "with a block" do
2393
+ let(:prices) do
2394
+ Namo.new([
2395
+ {symbol: 'BHP', date: '2025-02-15', close: 42.5},
2396
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0}
2397
+ ])
2398
+ end
2399
+
2400
+ let(:quarterly) do
2401
+ Namo.new([
2402
+ {symbol: 'BHP', quarter_end: '2024-12-31', eps: 1.0},
2403
+ {symbol: 'BHP', quarter_end: '2025-03-31', eps: 1.2}
2404
+ ])
2405
+ end
2406
+
2407
+ let(:most_recent_quarter) do
2408
+ proc do |row, candidates|
2409
+ candidates[quarter_end: ->(qe){qe <= row[:date]}].sort_by{|f| f[:quarter_end]}.last(1)
2410
+ end
2411
+ end
2412
+
2413
+ it "pairs each left row with the single match the block returns" do
2414
+ result = prices.*(quarterly, &most_recent_quarter)
2415
+ _(result.to_a).must_equal [
2416
+ {symbol: 'BHP', date: '2025-02-15', close: 42.5, quarter_end: '2024-12-31', eps: 1.0},
2417
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2025-03-31', eps: 1.2}
2418
+ ]
2419
+ end
2420
+
2421
+ it "drops a left row whose block returns an empty Namo" do
2422
+ early = Namo.new([
2423
+ {symbol: 'BHP', date: '2024-06-01', close: 40.0},
2424
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0}
2425
+ ])
2426
+ result = early.*(quarterly, &most_recent_quarter)
2427
+ _(result.to_a).must_equal [
2428
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2025-03-31', eps: 1.2}
2429
+ ]
2430
+ end
2431
+
2432
+ it "pairs each row when the block returns a multi-row Namo (selector, not reducer)" do
2433
+ left = Namo.new([{symbol: 'BHP', date: '2025-05-20', close: 44.0}])
2434
+ result = left.*(quarterly){|row, candidates| candidates}
2435
+ _(result.to_a).must_equal [
2436
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2024-12-31', eps: 1.0},
2437
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2025-03-31', eps: 1.2}
2438
+ ]
2439
+ end
2440
+
2441
+ it "selects on a derived dimension of other inside the block" do
2442
+ prices = Namo.new([
2443
+ {symbol: 'BHP', close: 42.5},
2444
+ {symbol: 'RIO', close: 118.3}
2445
+ ])
2446
+ funds = Namo.new([
2447
+ {symbol: 'BHP', price: 42.5, eps: 3.0},
2448
+ {symbol: 'RIO', price: 118.3, eps: 13.0}
2449
+ ])
2450
+ funds[:pe] = proc{|f| f[:price] / f[:eps]}
2451
+ result = prices.*(funds){|row, candidates| candidates[pe: ->(v){v && v < 10}]}
2452
+ _(result.values(:symbol)).must_equal ['RIO']
2453
+ _(result.values(:close)).must_equal [118.3]
2454
+ end
2455
+
2456
+ it "resolves a derived dimension of self referenced in the block" do
2457
+ dated = Namo.new([{symbol: 'BHP', date: '2025-05-20', close: 44.0}])
2458
+ dated[:cutoff] = proc{|r| r[:date]}
2459
+ result = dated.*(quarterly){|row, candidates| candidates[quarter_end: ->(qe){qe <= row[:cutoff]}].sort_by{|f| f[:quarter_end]}.last(1)}
2460
+ _(result.to_a).must_equal [
2461
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2025-03-31', eps: 1.2, cutoff: '2025-05-20'}
2462
+ ]
2463
+ end
2464
+
2465
+ it "produces the same result formulae as the no-block form" do
2466
+ ohlcv = Namo.new([
2467
+ {symbol: 'BHP', close: 42.5},
2468
+ {symbol: 'RIO', close: 118.3}
2469
+ ])
2470
+ fundamentals = Namo.new([
2471
+ {symbol: 'BHP', pe: 14.5},
2472
+ {symbol: 'RIO', pe: 9.2}
2473
+ ])
2474
+ ohlcv[:label] = proc{|r| "#{r[:symbol]}-self"}
2475
+ fundamentals[:flag] = proc{|r| "pe=#{r[:pe]}"}
2476
+ blocked = ohlcv.*(fundamentals){|row, candidates| candidates}
2477
+ plain = ohlcv * fundamentals
2478
+ _(blocked.derived_dimensions).must_equal plain.derived_dimensions
2479
+ _(blocked.values(:label)).must_equal plain.values(:label)
2480
+ _(blocked.values(:flag)).must_equal plain.values(:flag)
2481
+ end
2482
+
2483
+ it "leaves other's derived dimension unstored but resolvable on the result" do
2484
+ prices = Namo.new([{symbol: 'BHP', close: 42.5}])
2485
+ funds = Namo.new([{symbol: 'BHP', price: 42.5, eps: 3.0}])
2486
+ funds[:pe] = proc{|f| f[:price] / f[:eps]}
2487
+ result = prices.*(funds){|row, candidates| candidates}
2488
+ _(result.data_dimensions).wont_include :pe
2489
+ _(result.derived_dimensions).must_include :pe
2490
+ _(result.values(:pe)).must_equal [42.5 / 3.0]
2491
+ end
2492
+
2493
+ it "returns an instance of self's class" do
2494
+ subclass = Class.new(Namo)
2495
+ a = subclass.new([{symbol: 'BHP', close: 42.5}])
2496
+ b = Namo.new([{symbol: 'BHP', pe: 14.5}])
2497
+ result = a.*(b){|row, candidates| candidates}
2498
+ _(result.class).must_equal subclass
2499
+ end
2500
+
2501
+ it "still raises ArgumentError on no shared dimensions" do
2502
+ a = Namo.new([{symbol: 'BHP'}])
2503
+ b = Namo.new([{quarter: 'Q1'}])
2504
+ _ { a.*(b){|row, candidates| candidates} }.must_raise ArgumentError
2505
+ end
2506
+
2507
+ it "still raises TypeError on a non-Namo operand" do
2508
+ _ { ohlcv.*([{symbol: 'BHP'}]){|row, candidates| candidates} }.must_raise TypeError
2509
+ end
2510
+ end
2511
+ end
2512
+
2513
+ describe "#**" do
2514
+ let(:products) do
2515
+ Namo.new([{product: 'Widget'}, {product: 'Gadget'}])
2516
+ end
2517
+
2518
+ let(:quarters) do
2519
+ Namo.new([{quarter: 'Q1'}, {quarter: 'Q2'}])
2520
+ end
2521
+
2522
+ it "Cartesian-products two disjoint Namos" do
2523
+ result = products ** quarters
2524
+ _(result.to_a).must_equal [
2525
+ {product: 'Widget', quarter: 'Q1'},
2526
+ {product: 'Widget', quarter: 'Q2'},
2527
+ {product: 'Gadget', quarter: 'Q1'},
2528
+ {product: 'Gadget', quarter: 'Q2'}
2529
+ ]
2530
+ end
2531
+
2532
+ it "has self.data.length * other.data.length rows" do
2533
+ a = Namo.new([{x: 1}, {x: 2}, {x: 3}])
2534
+ b = Namo.new([{y: 'a'}, {y: 'b'}])
2535
+ _((a ** b).to_a.length).must_equal 6
2536
+ end
2537
+
2538
+ it "output dimensions are self.data_dimensions + other.data_dimensions" do
2539
+ result = products ** quarters
2540
+ _(result.dimensions).must_equal [:product, :quarter]
2541
+ end
2542
+
2543
+ it "preserves duplicates on either side multiplicatively" do
2544
+ a = Namo.new([{x: 1}, {x: 1}])
2545
+ b = Namo.new([{y: 'a'}, {y: 'a'}])
2546
+ result = a ** b
2547
+ _(result.to_a.length).must_equal 4
2548
+ end
2549
+
2550
+ it "carries formulae through from self" do
2551
+ products[:label] = proc{|r| "self: #{r[:product]}"}
2552
+ result = products ** quarters
2553
+ _(result.map{|row| row[:label]}).must_equal [
2554
+ 'self: Widget', 'self: Widget', 'self: Gadget', 'self: Gadget'
2555
+ ]
2556
+ end
2557
+
2558
+ it "merges formulae from other" do
2559
+ quarters[:flag] = proc{|r| "q=#{r[:quarter]}"}
2560
+ result = products ** quarters
2561
+ _(result.map{|row| row[:flag]}).must_equal ['q=Q1', 'q=Q2', 'q=Q1', 'q=Q2']
2562
+ end
2563
+
2564
+ it "prefers self's formulae on conflict" do
2565
+ products[:label] = proc{|r| "self: #{r[:product]}"}
2566
+ quarters[:label] = proc{|r| "other: #{r[:quarter]}"}
2567
+ result = products ** quarters
2568
+ _(result.map{|row| row[:label]}).must_equal [
2569
+ 'self: Widget', 'self: Widget', 'self: Gadget', 'self: Gadget'
2570
+ ]
2571
+ end
2572
+
2573
+ it "raises ArgumentError when any dimension is shared" do
2574
+ a = Namo.new([{symbol: 'BHP', close: 42.5}])
2575
+ b = Namo.new([{symbol: 'RIO', pe: 14.5}])
2576
+ err = _ { a ** b }.must_raise ArgumentError
2577
+ _(err.message).must_match(/dimensions in common, need no common dimensions/)
2578
+ end
2579
+
2580
+ it "raises TypeError on a non-Namo operand" do
2581
+ _ { products ** [{quarter: 'Q1'}] }.must_raise TypeError
2582
+ end
2583
+
2584
+ it "returns an instance of self's class" do
2585
+ subclass = Class.new(Namo)
2586
+ a = subclass.new([{product: 'Widget'}])
2587
+ b = Namo.new([{quarter: 'Q1'}])
2588
+ _((a ** b).class).must_equal subclass
2589
+ end
2590
+
2591
+ context "with a block" do
2592
+ let(:orders) do
2593
+ Namo.new([
2594
+ {order: 'A', weight: 5},
2595
+ {order: 'B', weight: 15}
2596
+ ])
2597
+ end
2598
+
2599
+ let(:tiers) do
2600
+ Namo.new([
2601
+ {tier: 'light', max_weight: 10},
2602
+ {tier: 'heavy', max_weight: 20}
2603
+ ])
2604
+ end
2605
+
2606
+ it "filters pairings by the selector block (one-to-many)" do
2607
+ result = orders.**(tiers){|row, candidates| candidates[max_weight: ->(w){w >= row[:weight]}]}
2608
+ _(result.to_a).must_equal [
2609
+ {order: 'A', weight: 5, tier: 'light', max_weight: 10},
2610
+ {order: 'A', weight: 5, tier: 'heavy', max_weight: 20},
2611
+ {order: 'B', weight: 15, tier: 'heavy', max_weight: 20}
2612
+ ]
2613
+ end
2614
+
2615
+ it "drops a left row whose block returns an empty Namo" do
2616
+ with_unservable = Namo.new([
2617
+ {order: 'C', weight: 50},
2618
+ {order: 'A', weight: 5}
2619
+ ])
2620
+ result = with_unservable.**(tiers){|row, candidates| candidates[max_weight: ->(w){w >= row[:weight]}]}
2621
+ _(result.to_a).must_equal [
2622
+ {order: 'A', weight: 5, tier: 'light', max_weight: 10},
2623
+ {order: 'A', weight: 5, tier: 'heavy', max_weight: 20}
2624
+ ]
2625
+ end
2626
+
2627
+ it "reproduces the no-block product when the block returns all candidates" do
2628
+ blocked = products.**(quarters){|row, candidates| candidates}
2629
+ plain = products ** quarters
2630
+ _(blocked.to_a).must_equal plain.to_a
2631
+ end
2632
+
2633
+ it "selects on a derived dimension of other inside the block" do
2634
+ weighted_tiers = Namo.new([
2635
+ {tier: 'light', max_weight: 10},
2636
+ {tier: 'heavy', max_weight: 20}
2637
+ ])
2638
+ weighted_tiers[:premium] = proc{|t| t[:max_weight] > 15}
2639
+ result = orders.**(weighted_tiers){|row, candidates| candidates[premium: ->(v){v}]}
2640
+ _(result.to_a).must_equal [
2641
+ {order: 'A', weight: 5, tier: 'heavy', max_weight: 20, premium: true},
2642
+ {order: 'B', weight: 15, tier: 'heavy', max_weight: 20, premium: true}
2643
+ ]
2644
+ end
2645
+
2646
+ it "produces the same result formulae as the no-block form" do
2647
+ products[:plabel] = proc{|r| "p=#{r[:product]}"}
2648
+ quarters[:qlabel] = proc{|r| "q=#{r[:quarter]}"}
2649
+ blocked = products.**(quarters){|row, candidates| candidates}
2650
+ plain = products ** quarters
2651
+ _(blocked.derived_dimensions).must_equal plain.derived_dimensions
2652
+ _(blocked.values(:plabel)).must_equal plain.values(:plabel)
2653
+ _(blocked.values(:qlabel)).must_equal plain.values(:qlabel)
2654
+ end
2655
+
2656
+ it "returns an instance of self's class" do
2657
+ subclass = Class.new(Namo)
2658
+ a = subclass.new([{product: 'Widget'}])
2659
+ b = Namo.new([{quarter: 'Q1'}])
2660
+ result = a.**(b){|row, candidates| candidates}
2661
+ _(result.class).must_equal subclass
2662
+ end
2663
+
2664
+ it "still raises ArgumentError when a dimension is shared" do
2665
+ a = Namo.new([{symbol: 'BHP', close: 42.5}])
2666
+ b = Namo.new([{symbol: 'RIO', pe: 14.5}])
2667
+ _ { a.**(b){|row, candidates| candidates} }.must_raise ArgumentError
2668
+ end
2669
+
2670
+ it "still raises TypeError on a non-Namo operand" do
2671
+ _ { products.**([{quarter: 'Q1'}]){|row, candidates| candidates} }.must_raise TypeError
2672
+ end
2673
+ end
2674
+ end
2675
+
2676
+ describe "#/" do
2677
+ let(:combined) do
2678
+ Namo.new([
2679
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5, pe: 14.5},
2680
+ {symbol: 'RIO', date: '2025-01-01', close: 118.3, pe: 9.2}
2681
+ ])
2682
+ end
2683
+
2684
+ let(:fundamentals) do
2685
+ Namo.new([
2686
+ {symbol: 'BHP', pe: 14.5},
2687
+ {symbol: 'RIO', pe: 9.2}
2688
+ ])
2689
+ end
2690
+
2691
+ it "removes dimensions present in both self and other (the intersection)" do
2692
+ result = combined / fundamentals
2693
+ _(result.dimensions).must_equal [:date, :close]
2694
+ end
2695
+
2696
+ it "preserves dimensions exclusive to self" do
2697
+ result = combined / fundamentals
2698
+ _(result.to_a).must_equal [
2699
+ {date: '2025-01-01', close: 42.5},
2700
+ {date: '2025-01-01', close: 118.3}
2701
+ ]
2702
+ end
2703
+
2704
+ it "dedupes rows that collide after projection" do
2705
+ a = Namo.new([
2706
+ {symbol: 'BHP', close: 42.5},
2707
+ {symbol: 'RIO', close: 42.5}
2708
+ ])
2709
+ b = Namo.new([{symbol: 'X'}])
2710
+ result = a / b
2711
+ _(result.to_a).must_equal [{close: 42.5}]
2712
+ end
2713
+
2714
+ it "carries formulae through from self" do
2715
+ combined[:label] = proc{|r| "row: #{r[:close]}"}
2716
+ result = combined / fundamentals
2717
+ _(result.map{|row| row[:label]}).must_equal ['row: 42.5', 'row: 118.3']
2718
+ end
2719
+
2720
+ it "is a no-op when self and other share no dimensions" do
2721
+ shipments = Namo.new([{order_id: 1, weight: 10}])
2722
+ weather = Namo.new([{date: '2025-01-01', temperature: 22}])
2723
+ _(shipments / weather).must_equal shipments
2724
+ end
2725
+
2726
+ it "ignores dimensions present in other but not in self" do
2727
+ a = Namo.new([{symbol: 'BHP', close: 42.5}])
2728
+ b = Namo.new([{symbol: 'BHP', pe: 14.5, sector: 'Mining'}])
2729
+ result = a / b
2730
+ _(result.dimensions).must_equal [:close]
2731
+ end
2732
+
2733
+ it "is idempotent" do
2734
+ first = combined / fundamentals
2735
+ second = first / fundamentals
2736
+ _(second).must_equal first
2737
+ end
2738
+
2739
+ it "raises TypeError on a non-Namo operand" do
2740
+ _ { combined / [{symbol: 'BHP'}] }.must_raise TypeError
2741
+ end
2742
+
2743
+ it "returns an instance of self's class" do
2744
+ subclass = Class.new(Namo)
2745
+ a = subclass.new([{symbol: 'BHP', close: 42.5}])
2746
+ b = Namo.new([{symbol: 'BHP', pe: 14.5}])
2747
+ _((a / b).class).must_equal subclass
2748
+ end
2749
+ end
2750
+
2751
+ describe "composition round-trip" do
2752
+ it "satisfies (a ** b) / b == a for disjoint a and b" do
2753
+ a = Namo.new([{symbol: 'BHP'}, {symbol: 'RIO'}])
2754
+ b = Namo.new([{quarter: 'Q1'}, {quarter: 'Q2'}])
2755
+ _((a ** b) / b).must_equal a
2756
+ end
2757
+
2758
+ it "satisfies (a * b) / b == a[-:shared] for a and b with shared dimensions (shared dimensions lost)" do
2759
+ a = Namo.new([{symbol: 'BHP', close: 42.5}, {symbol: 'RIO', close: 118.3}])
2760
+ b = Namo.new([{symbol: 'BHP', pe: 14.5}, {symbol: 'RIO', pe: 9.2}])
2761
+ _((a * b) / b).must_equal Namo.new([{close: 42.5}, {close: 118.3}])
2762
+ end
2763
+ end
2764
+
2765
+ describe "#==" do
2766
+ it "is true for same data, same order" do
2767
+ a = Namo.new([{x: 1}, {x: 2}])
2768
+ b = Namo.new([{x: 1}, {x: 2}])
2769
+ _(a == b).must_equal true
2770
+ end
2771
+
2772
+ it "is true for same data, different order" do
2773
+ a = Namo.new([{x: 1}, {x: 2}])
2774
+ b = Namo.new([{x: 2}, {x: 1}])
2775
+ _(a == b).must_equal true
2776
+ end
2777
+
2778
+ it "is false for different data" do
2779
+ a = Namo.new([{x: 1}, {x: 2}])
2780
+ b = Namo.new([{x: 1}, {x: 3}])
2781
+ _(a == b).must_equal false
2782
+ end
2783
+
2784
+ it "is multiset-aware: duplicates count" do
2785
+ a = Namo.new([{x: 1}])
2786
+ b = Namo.new([{x: 1}, {x: 1}])
2787
+ _(a == b).must_equal false
2788
+ end
2789
+
2790
+ it "is true across subclasses with same data" do
2791
+ subclass = Class.new(Namo)
2792
+ a = Namo.new([{x: 1}, {x: 2}])
2793
+ b = subclass.new([{x: 1}, {x: 2}])
2794
+ _(a == b).must_equal true
2795
+ end
2796
+
2797
+ it "ignores formulae" do
2798
+ a = Namo.new([{x: 1}, {x: 2}])
2799
+ b = Namo.new([{x: 1}, {x: 2}])
2800
+ b[:y] = proc{|row| row[:x] * 2}
2801
+ _(a == b).must_equal true
2802
+ end
2803
+
2804
+ it "is false against a non-Namo" do
2805
+ a = Namo.new([{x: 1}, {x: 2}])
2806
+ _(a == [{x: 1}, {x: 2}]).must_equal false
2807
+ _(a == 'string').must_equal false
2808
+ _(a == nil).must_equal false
2809
+ end
2810
+
2811
+ it "compares rows whose dimension mixes nil and non-nil values" do
2812
+ a = Namo.new([{symbol: 'BHP', sector: 'Mining'}, {symbol: 'CBA', sector: nil}])
2813
+ b = Namo.new([{symbol: 'CBA', sector: nil}, {symbol: 'BHP', sector: 'Mining'}])
2814
+ _(a == b).must_equal true
2815
+ end
2816
+
2817
+ it "is false for differing rows when a dimension contains nil" do
2818
+ a = Namo.new([{symbol: 'BHP', sector: 'Mining'}, {symbol: 'CBA', sector: nil}])
2819
+ b = Namo.new([{symbol: 'BHP', sector: 'Mining'}, {symbol: 'CBA', sector: 'Banking'}])
2820
+ _(a == b).must_equal false
2821
+ end
2822
+
2823
+ it "ignores dimension (key) order within a row" do
2824
+ a = Namo.new([{a: 1, b: 2, c: 3}])
2825
+ b = Namo.new([{c: 3, b: 2, a: 1}])
2826
+ _(a == b).must_equal true
2827
+ end
2828
+
2829
+ it "is type-strict on values, consistent with the subset operators" do
2830
+ a = Namo.new([{x: 1}])
2831
+ b = Namo.new([{x: 1.0}])
2832
+ _(a == b).must_equal false
2833
+ _(a <= b).must_equal false
2834
+ end
2835
+ end
2836
+
2837
+ describe "#===" do
2838
+ it "is true when dimensions and formulae match, ignoring rows" do
2839
+ a = Namo.new([{x: 1}])
2840
+ b = Namo.new([{x: 2}, {x: 3}])
2841
+ _(a === b).must_equal true
2842
+ end
2843
+
2844
+ it "is false when formulae differ" do
2845
+ a = Namo.new([{x: 1}])
2846
+ b = Namo.new([{x: 1}])
2847
+ b[:doubled] = proc{|row| row[:x] * 2}
2848
+ _(a === b).must_equal false
2849
+ end
2850
+
2851
+ it "is true when formulae have the same names, regardless of proc identity" do
2852
+ a = Namo.new([{x: 1}])
2853
+ a[:doubled] = proc{|row| row[:x] * 2}
2854
+ b = Namo.new([{x: 1}])
2855
+ b[:doubled] = proc{|row| row[:x] * 2}
2856
+ _(a === b).must_equal true
2857
+ end
2858
+
2859
+ it "is false when dimensions differ" do
2860
+ a = Namo.new([{x: 1}])
2861
+ b = Namo.new([{y: 1}])
2862
+ _(a === b).must_equal false
2863
+ end
2864
+
2865
+ it "is true when dimensions are in different order" do
2866
+ a = Namo.new([{x: 1, y: 2}])
2867
+ b = Namo.new([{y: 9, x: 8}])
2868
+ _(a === b).must_equal true
2869
+ end
2870
+
2871
+ it "is false for a non-Namo and does not raise" do
2872
+ a = Namo.new([{x: 1}])
2873
+ _(a === [{x: 1}]).must_equal false
2874
+ _(a === 'string').must_equal false
2875
+ _(a === nil).must_equal false
2876
+ end
2877
+
2878
+ it "drives case statement dispatch on analytical type" do
2879
+ template = Namo.new([{x: 0}])
2880
+ candidate = Namo.new([{x: 5}, {x: 6}])
2881
+ result = case candidate
2882
+ when template; :matched
2883
+ else; :not_matched
2884
+ end
2885
+ _(result).must_equal :matched
2886
+ end
2887
+ end
2888
+
2889
+ describe "#eql?" do
2890
+ it "is true for same class, same data, no formulae" do
2891
+ a = Namo.new([{x: 1}, {x: 2}])
2892
+ b = Namo.new([{x: 1}, {x: 2}])
2893
+ _(a.eql?(b)).must_equal true
2894
+ end
2895
+
2896
+ it "is true for same class, same data, different order" do
2897
+ a = Namo.new([{x: 1}, {x: 2}])
2898
+ b = Namo.new([{x: 2}, {x: 1}])
2899
+ _(a.eql?(b)).must_equal true
2900
+ end
2901
+
2902
+ it "is true when formula names match, regardless of proc identity" do
2903
+ a = Namo.new([{x: 1}, {x: 2}])
2904
+ a[:y] = proc{|row| row[:x] * 2}
2905
+ b = Namo.new([{x: 1}, {x: 2}])
2906
+ b[:y] = proc{|row| row[:x] * 2}
2907
+ _(a.eql?(b)).must_equal true
2908
+ end
2909
+
2910
+ it "is false when formula names differ" do
2911
+ a = Namo.new([{x: 1}, {x: 2}])
2912
+ a[:doubled] = proc{|row| row[:x] * 2}
2913
+ b = Namo.new([{x: 1}, {x: 2}])
2914
+ b[:tripled] = proc{|row| row[:x] * 3}
2915
+ _(a.eql?(b)).must_equal false
2916
+ end
2917
+
2918
+ it "is false across different classes" do
2919
+ subclass = Class.new(Namo)
2920
+ a = Namo.new([{x: 1}, {x: 2}])
2921
+ b = subclass.new([{x: 1}, {x: 2}])
2922
+ _(a.eql?(b)).must_equal false
2923
+ end
2924
+ end
2925
+
2926
+ describe "#hash" do
2927
+ it "is equal for set-equal Namos" do
2928
+ a = Namo.new([{x: 1}, {x: 2}])
2929
+ b = Namo.new([{x: 2}, {x: 1}])
2930
+ _(a.hash).must_equal b.hash
2931
+ end
2932
+
2933
+ it "differs when formula names differ" do
2934
+ a = Namo.new([{x: 1}, {x: 2}])
2935
+ b = Namo.new([{x: 1}, {x: 2}])
2936
+ b[:y] = proc{|row| row[:x] * 2}
2937
+ _(a.hash).wont_equal b.hash
2938
+ end
2939
+
2940
+ it "is equal when formula names match, regardless of proc identity" do
2941
+ a = Namo.new([{x: 1}, {x: 2}])
2942
+ a[:y] = proc{|row| row[:x] * 2}
2943
+ b = Namo.new([{x: 1}, {x: 2}])
2944
+ b[:y] = proc{|row| row[:x] * 2}
2945
+ _(a.hash).must_equal b.hash
2946
+ end
2947
+
2948
+ it "differs across classes" do
2949
+ subclass = Class.new(Namo)
2950
+ a = Namo.new([{x: 1}, {x: 2}])
2951
+ b = subclass.new([{x: 1}, {x: 2}])
2952
+ _(a.hash).wont_equal b.hash
2953
+ end
2954
+
2955
+ it "makes Namos usable as Hash keys" do
2956
+ a = Namo.new([{x: 1}, {x: 2}])
2957
+ b = Namo.new([{x: 2}, {x: 1}])
2958
+ h = {a => 'first'}
2959
+ _(h[b]).must_equal 'first'
2960
+ end
2961
+ end
2962
+
2963
+ describe "#<, #<=, #>, #>=" do
2964
+ let(:small) { Namo.new([{x: 1}, {x: 2}]) }
2965
+ let(:large) { Namo.new([{x: 1}, {x: 2}, {x: 3}]) }
2966
+ let(:disjoint) { Namo.new([{x: 4}, {x: 5}]) }
2967
+
2968
+ it "recognises proper subset" do
2969
+ _(small < large).must_equal true
2970
+ _(small <= large).must_equal true
2971
+ _(large > small).must_equal true
2972
+ _(large >= small).must_equal true
2973
+ end
2974
+
2975
+ it "treats equal sets as <= and >= but not < or >" do
2976
+ copy = Namo.new([{x: 2}, {x: 1}])
2977
+ _(small <= copy).must_equal true
2978
+ _(small >= copy).must_equal true
2979
+ _(small < copy).must_equal false
2980
+ _(small > copy).must_equal false
2981
+ end
2982
+
2983
+ it "treats disjoint sets as neither subset nor superset" do
2984
+ _(small <= disjoint).must_equal false
2985
+ _(small >= disjoint).must_equal false
2986
+ _(small < disjoint).must_equal false
2987
+ _(small > disjoint).must_equal false
2988
+ end
2989
+
2990
+ it "is multiset-aware: a single row is a proper subset of two of the same row" do
2991
+ one = Namo.new([{x: 1}])
2992
+ two = Namo.new([{x: 1}, {x: 1}])
2993
+ _(one < two).must_equal true
2994
+ _(one <= two).must_equal true
2995
+ _(two <= one).must_equal false
2996
+ _(two < one).must_equal false
2997
+ end
2998
+
2999
+ it "raises ArgumentError on mismatched dimensions" do
3000
+ other = Namo.new([{y: 1}])
3001
+ _ { small < other }.must_raise ArgumentError
3002
+ _ { small <= other }.must_raise ArgumentError
3003
+ _ { small > other }.must_raise ArgumentError
3004
+ _ { small >= other }.must_raise ArgumentError
3005
+ end
3006
+
3007
+ it "raises TypeError on non-Namo" do
3008
+ _ { small < [{x: 1}] }.must_raise TypeError
3009
+ _ { small <= 'string' }.must_raise TypeError
3010
+ _ { small > nil }.must_raise TypeError
3011
+ _ { small >= 42 }.must_raise TypeError
3012
+ end
3013
+ end
3014
+
3015
+ describe "#equal?" do
3016
+ it "is false for distinct objects" do
3017
+ a = Namo.new([{x: 1}])
3018
+ b = Namo.new([{x: 1}])
3019
+ _(a.equal?(b)).must_equal false
3020
+ end
3021
+
3022
+ it "is true for the same object" do
3023
+ a = Namo.new([{x: 1}])
3024
+ _(a.equal?(a)).must_equal true
3025
+ end
3026
+ end
3027
+
3028
+ describe "dimension-mismatch error message" do
3029
+ it "names both dimension lists" do
3030
+ a = Namo.new([{x: 1}])
3031
+ b = Namo.new([{y: 1}])
3032
+ err = _ { a + b }.must_raise ArgumentError
3033
+ _(err.message).must_match(/dimensions don't match/)
3034
+ _(err.message).must_match(/\[:x\]/)
3035
+ _(err.message).must_match(/\[:y\]/)
3036
+ end
3037
+ end
3038
+
3039
+ describe "non-Namo comparison error message" do
3040
+ it "names the offending class" do
3041
+ a = Namo.new([{x: 1}])
3042
+ err = _ { a < 'string' }.must_raise TypeError
3043
+ _(err.message).must_match(/can't compare Namo with/)
3044
+ _(err.message).must_match(/String/)
3045
+ end
3046
+ end
3047
+
3048
+ describe "non-Namo set operation error message" do
3049
+ it "raises TypeError on non-Namo for #+, #-, #&, #|, #^" do
3050
+ a = Namo.new([{x: 1}])
3051
+ _ { a + [{x: 1}] }.must_raise TypeError
3052
+ _ { a - 'string' }.must_raise TypeError
3053
+ _ { a & nil }.must_raise TypeError
3054
+ _ { a | 42 }.must_raise TypeError
3055
+ _ { a ^ :symbol }.must_raise TypeError
3056
+ end
3057
+
3058
+ it "names the offending class" do
3059
+ a = Namo.new([{x: 1}])
3060
+ err = _ { a + 'string' }.must_raise TypeError
3061
+ _(err.message).must_match(/can't compare Namo with/)
3062
+ _(err.message).must_match(/String/)
3063
+ end
3064
+ end
3065
+
3066
+ describe "#to_a" do
3067
+ it "returns the data as an array of hashes" do
3068
+ _(sales.to_a).must_equal sample_data
3069
+ end
3070
+
3071
+ it "carries a derived dimension alongside the stored ones" do
3072
+ sales[:revenue] = proc{|row| row[:price] * row[:quantity]}
3073
+ _(sales.to_a.first).must_equal(
3074
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100, revenue: 1000.0})
3075
+ end
3076
+
3077
+ it "agrees with to_h about which dimensions there are" do
3078
+ sales[:revenue] = proc{|row| row[:price] * row[:quantity]}
3079
+ _(sales.to_a.first.keys).must_equal sales.to_h.keys
3080
+ _(sales.to_a.first.keys).must_equal sales.dimensions
3081
+ end
3082
+
3083
+ it "omits a formula which cannot be materialised without its arguments" do
3084
+ sales[:revenue] = proc{|row| row[:price] * row[:quantity]}
3085
+ sales[:scaled] = proc{|row, namo, factor| row[:price] * factor}
3086
+ _(sales.to_a.first.keys).wont_include :scaled
3087
+ _(sales.to_a.first.keys).must_include :revenue
3088
+ _(sales.derived_dimensions).must_include :scaled
3089
+ end
3090
+
3091
+ it "raises where a carried formula's inputs were projected away, as 0.16.0 settled" do
3092
+ sales[:revenue] = proc{|row| row[:price] * row[:quantity]}
3093
+ _(->{sales[:product, :quarter].to_a}).must_raise NoMethodError
3094
+ end
3095
+
3096
+ it "leaves data as the stored rows" do
3097
+ sales[:revenue] = proc{|row| row[:price] * row[:quantity]}
3098
+ _(sales.data).must_equal sample_data
3099
+ _(sales.data.first.keys).wont_include :revenue
3100
+ end
3101
+
3102
+ it "returns fresh rows rather than the stored ones" do
3103
+ _(sales.to_a.first).wont_be_same_as sales.data.first
3104
+ end
3105
+ end
3106
+
3107
+ describe "#group_by" do
3108
+ let(:price_data) do
3109
+ [
3110
+ {symbol: 'BHP', date: 1, close: 42.5, pe: 12.0},
3111
+ {symbol: 'BHP', date: 2, close: 43.0, pe: 12.0},
3112
+ {symbol: 'RIO', date: 1, close: 118.3, pe: 9.0},
3113
+ {symbol: 'CBA', date: 1, close: 100.0, pe: 22.0}
3114
+ ]
3115
+ end
3116
+ let(:prices) { Namo.new(price_data) }
3117
+
3118
+ it "returns a Namo::Collection" do
3119
+ _(prices.group_by(:symbol)).must_be_kind_of Namo::Collection
3120
+ end
3121
+
3122
+ it "has one member per distinct value of the dimension" do
3123
+ collection = prices.group_by(:symbol)
3124
+ _(collection.members.length).must_equal 3
3125
+ _(collection.members.map(&:name)).must_equal ['BHP', 'RIO', 'CBA']
3126
+ end
3127
+
3128
+ it "gives each member exactly the rows matching its group value" do
3129
+ collection = prices.group_by(:symbol)
3130
+ _(collection.find('BHP').values(:date)).must_equal [1, 2]
3131
+ _(collection.find('RIO').values(:date)).must_equal [1]
3132
+ end
3133
+
3134
+ it "retains the grouping dimension in each member (it is not consumed)" do
3135
+ _(prices.group_by(:symbol).find('BHP').data_dimensions).must_include :symbol
3136
+ end
3137
+
3138
+ it "names each member by its group value, found via find" do
3139
+ _(prices.group_by(:symbol).find('CBA').values(:close)).must_equal [100.0]
3140
+ end
3141
+
3142
+ it "carries the parent's formulae into each member" do
3143
+ prices[:cheap] = proc{|r| r[:pe] < 15}
3144
+ _(prices.group_by(:symbol).find('BHP').values(:cheap)).must_equal [true, true]
3145
+ end
3146
+
3147
+ it "preserves the receiver's class in each member" do
3148
+ subclass = Class.new(Namo)
3149
+ collection = subclass.new(price_data).group_by(:symbol)
3150
+ _(collection.members.first).must_be_instance_of subclass
3151
+ end
3152
+
3153
+ it "does not mutate the receiver" do
3154
+ prices.group_by(:symbol)
3155
+ _(prices.data).must_equal price_data
3156
+ end
3157
+
3158
+ it "returns an empty Collection for an empty Namo" do
3159
+ collection = Namo.new.group_by(:symbol)
3160
+ _(collection).must_be_kind_of Namo::Collection
3161
+ _(collection.members).must_equal []
3162
+ end
3163
+
3164
+ context "the uniform inversion law" do
3165
+ it "round-trips exactly on a data dimension" do
3166
+ _(prices.group_by(:symbol).as_detail(:symbol)).must_equal prices
3167
+ end
3168
+
3169
+ it "round-trips against the materialised form on a formula dimension" do
3170
+ prices[:value_score] = proc{|r| r[:pe] < 10 ? 2 : r[:pe] < 15 ? 1 : 0}
3171
+ recovered = prices.group_by(:value_score).as_detail(:value_score)
3172
+ _(recovered).must_equal prices[*prices.data_dimensions, :value_score]
3173
+ end
3174
+ end
3175
+
3176
+ context "grouping by a formula" do
3177
+ before do
3178
+ prices[:value_score] = proc{|r| r[:pe] < 10 ? 2 : r[:pe] < 15 ? 1 : 0}
3179
+ end
3180
+
3181
+ it "partitions by the formula's value" do
3182
+ collection = prices.group_by(:value_score)
3183
+ _(collection.members.map(&:name).sort).must_equal [0, 1, 2]
3184
+ _(collection.find(1).values(:symbol)).must_equal ['BHP', 'BHP']
3185
+ end
3186
+
3187
+ it "materialises the grouped-by formula into a data column in each member" do
3188
+ member = prices.group_by(:value_score).find(1)
3189
+ _(member.data_dimensions).must_include :value_score
3190
+ _(member.derived_dimensions).wont_include :value_score
3191
+ end
3192
+
3193
+ it "alters only the grouped-by formula, carrying the rest through live" do
3194
+ prices[:tier] = proc{|r| r[:value_score] >= 1 ? 'good' : 'poor'}
3195
+ member = prices.group_by(:value_score).find(1)
3196
+ _(member.derived_dimensions).must_include :tier
3197
+ _(member.derived_dimensions).wont_include :value_score
3198
+ _(member.values(:tier)).must_equal ['good', 'good']
3199
+ end
3200
+
3201
+ it "raises for a parameterised formula it cannot materialise" do
3202
+ prices[:sma] = proc{|row, namo, field, period| 0}
3203
+ _(proc{prices.group_by(:sma)}).must_raise ArgumentError
3204
+ end
3205
+ end
3206
+
3207
+ context "a nil-valued grouping dimension" do
3208
+ let(:sector_data) do
3209
+ [
3210
+ {symbol: 'BHP', sector: 'Mining'},
3211
+ {symbol: 'RIO', sector: 'Mining'},
3212
+ {symbol: 'XYZ', sector: nil}
3213
+ ]
3214
+ end
3215
+ let(:stocks) { Namo.new(sector_data) }
3216
+
3217
+ it "produces a nil-named member holding the nil-valued rows" do
3218
+ collection = stocks.group_by(:sector)
3219
+ nil_member = collection.members.find{|m| m.name.nil?}
3220
+ _(nil_member).wont_be_nil
3221
+ _(nil_member.values(:symbol)).must_equal ['XYZ']
3222
+ end
3223
+
3224
+ it "drops no rows and still round-trips" do
3225
+ _(stocks.group_by(:sector).as_detail(:sector)).must_equal stocks
3226
+ end
3227
+ end
3228
+
3229
+ context "consistency with assembly" do
3230
+ it "produces a Collection on which summary and detail behave as on an assembled one" do
3231
+ collection = prices.group_by(:symbol)
3232
+ _(collection.summary(:close, reducer: :sum).values(:member)).must_equal ['BHP', 'RIO', 'CBA']
3233
+ _(collection.detail.values(:close)).must_equal [42.5, 43.0, 118.3, 100.0]
3234
+ end
3235
+ end
3236
+
3237
+ context "single-pass assembly" do
3238
+ # Collection#<< rebuilds @data = detail.data over all members-so-far on every
3239
+ # append, so appending one member per group re-materialised the data g times
3240
+ # (O(rows·groups)). group_by now hands all members to a single <<: one
3241
+ # materialisation whatever the number of groups.
3242
+ def detail_calls_assembling(namo, dimension)
3243
+ calls = 0
3244
+ tracer = TracePoint.new(:call) do |tp|
3245
+ calls += 1 if tp.method_id == :detail && tp.defined_class == Namo::Collection
3246
+ end
3247
+ tracer.enable{namo.group_by(dimension)}
3248
+ calls
3249
+ end
3250
+
3251
+ it "materialises the assembled data once, whatever the number of groups" do
3252
+ few = Namo.new((1..3).map{|i| {symbol: "S#{i}", close: i.to_f}})
3253
+ many = Namo.new((1..30).map{|i| {symbol: "S#{i}", close: i.to_f}})
3254
+ _(detail_calls_assembling(few, :symbol)).must_equal 1
3255
+ _(detail_calls_assembling(many, :symbol)).must_equal 1
3256
+ end
3257
+ end
3258
+ end
3259
+
3260
+ describe "#inspect" do
3261
+ it "renders the class and the rows" do
3262
+ _(Namo.new([{a: 1}]).inspect).must_equal "#<Namo [\n {a: 1}\n]>"
3263
+ end
3264
+
3265
+ it "renders an empty Namo without a row block" do
3266
+ _(Namo.new([]).inspect).must_equal "#<Namo []>"
3267
+ end
3268
+
3269
+ it "shows a derived dimension with its value, in among the stored ones" do
3270
+ namo = Namo.new([{a: 1}])
3271
+ namo[:b] = proc{|row| row[:a] + 1}
3272
+ _(namo.inspect).must_equal "#<Namo [\n {a: 1, b: 2}\n]>"
3273
+ end
3274
+
3275
+ it "shows a collection-scoped derived value" do
3276
+ namo = Namo.new([{a: 1}, {a: 2}])
3277
+ namo[:total] = proc{|row, namo| namo.values(:a).sum}
3278
+ _(namo.inspect).must_equal "#<Namo [\n {a: 1, total: 3},\n {a: 2, total: 3}\n]>"
3279
+ end
3280
+
3281
+ it "names a derived dimension on a Namo with no rows, there being nowhere else to learn of it" do
3282
+ namo = Namo.new([])
3283
+ namo[:b] = proc{|row| row[:a] + 1}
3284
+ _(namo.inspect).must_equal "#<Namo [] derived: [:b]>"
3285
+ end
3286
+
3287
+ it "names a derived dimension whose formula raises, without a value" do
3288
+ namo = Namo.new([{a: 1}])
3289
+ namo[:b] = proc{|row| raise 'no value to show'}
3290
+ _(namo.inspect).must_equal "#<Namo [\n {a: 1}\n] derived: [:b]>"
3291
+ end
3292
+
3293
+ it "names a derived dimension whose formula wants arguments, without a value" do
3294
+ namo = Namo.new([{a: 1}])
3295
+ namo[:b] = proc{|row, namo, factor| row[:a] * factor}
3296
+ _(namo.inspect).must_equal "#<Namo [\n {a: 1}\n] derived: [:b]>"
3297
+ end
3298
+
3299
+ it "includes the name when there is one" do
3300
+ _(Namo.new([{a: 1}], name: :sales).inspect).must_match(/\A#<Namo :sales \[/)
3301
+ end
3302
+
3303
+ it "omits the name when there is none" do
3304
+ _(Namo.new([{a: 1}]).inspect).must_match(/\A#<Namo \[/)
3305
+ end
3306
+
3307
+ it "truncates beyond INSPECTED_ROWS and says how many are left" do
3308
+ namo = Namo.new((1..25).map{|i| {a: i}})
3309
+ _(namo.inspect.scan(/\{a: \d+\}/).length).must_equal Namo::INSPECTED_ROWS
3310
+ _(namo.inspect).must_match(/\.\.\. 15 more rows/)
3311
+ end
3312
+
3313
+ it "does not truncate at exactly INSPECTED_ROWS" do
3314
+ namo = Namo.new((1..Namo::INSPECTED_ROWS).map{|i| {a: i}})
3315
+ _(namo.inspect).wont_match(/more rows/)
3316
+ end
3317
+
3318
+ it "reports the subclass rather than Namo" do
3319
+ Object.const_set(:InspectedSubAssembly, Class.new(Namo)) unless defined?(InspectedSubAssembly)
3320
+ _(InspectedSubAssembly.new([{a: 1}]).inspect).must_match(/\A#<InspectedSubAssembly \[/)
3321
+ end
3322
+
3323
+ it "does not grow with the data beyond the truncation point" do
3324
+ _(Namo.new((1..10_000).map{|i| {a: i}}).inspect.length).must_be :<, 500
3325
+ end
3326
+ end
3327
+
3328
+ end