namo 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/test/namo_test.rb ADDED
@@ -0,0 +1,750 @@
1
+ require 'minitest/autorun'
2
+ require 'minitest-spec-context'
3
+
4
+ require_relative '../lib/namo'
5
+
6
+ describe Namo do
7
+ let(:sample_data) do
8
+ [
9
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
10
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
11
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
12
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
13
+ ]
14
+ end
15
+
16
+ let(:sales) do
17
+ Namo.new(sample_data)
18
+ end
19
+
20
+ describe "#dimensions" do
21
+ it "infers dimensions from hash keys" do
22
+ _(sales.dimensions).must_equal [:product, :quarter, :price, :quantity]
23
+ end
24
+ end
25
+
26
+ describe "#coordinates" do
27
+ it "extracts unique values for each dimension" do
28
+ _(sales.coordinates).must_equal ({
29
+ product: ['Widget', 'Gadget'],
30
+ quarter: ['Q1', 'Q2'],
31
+ price: [10.0, 25.0],
32
+ quantity: [100, 150, 40, 60]
33
+ })
34
+ _(sales.coordinates[:product]).must_equal ['Widget', 'Gadget']
35
+ _(sales.coordinates[:quarter]).must_equal ['Q1', 'Q2']
36
+ end
37
+ end
38
+
39
+ describe "#[]" do
40
+ context "selection" do
41
+ it "selects by single coordinate" do
42
+ result = sales[product: 'Widget']
43
+ _(result.coordinates[:product]).must_equal ['Widget']
44
+ _(result.to_a.count).must_equal 2
45
+ _(result.to_a.map{|row| row[:product]}).must_equal ['Widget', 'Widget']
46
+ end
47
+
48
+ it "selects by array of coordinates" do
49
+ result = sales[quarter: ['Q1']]
50
+ _(result.coordinates[:quarter]).must_equal ['Q1']
51
+ _(result.to_a.count).must_equal 2
52
+ end
53
+
54
+ it "selects by multiple dimensions" do
55
+ result = sales[product: 'Widget', quarter: 'Q1']
56
+ _(result.to_a.count).must_equal 1
57
+ _(result.to_a).must_equal [{product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100}]
58
+ end
59
+
60
+ it "returns a Namo instance" do
61
+ result = sales[product: 'Widget']
62
+ _(result.coordinates[:product]).must_equal ['Widget']
63
+ end
64
+ end
65
+
66
+ context "projection" do
67
+ it "projects to named dimensions" do
68
+ result = sales[:product, :price]
69
+ _(result.dimensions).must_equal [:product, :price]
70
+ _(result.coordinates[:product]).must_equal ['Widget', 'Gadget']
71
+ _(result.to_a.count).must_equal 4
72
+ end
73
+ end
74
+
75
+ context "selection and projection" do
76
+ it "can use them together" do
77
+ result = sales[:price, product: 'Widget']
78
+ _(result.to_a.count).must_equal 2
79
+ _(result.to_a).must_equal [{price: 10.0}, {price: 10.0}]
80
+ end
81
+
82
+ it "can chain them" do
83
+ result = sales[product: 'Widget'][:price]
84
+ _(result.to_a.count).must_equal 2
85
+ _(result.to_a).must_equal [{price: 10.0}, {price: 10.0}]
86
+ end
87
+ end
88
+
89
+ context "contraction" do
90
+ it "removes named dimensions" do
91
+ result = sales[-:price, -:quantity]
92
+ _(result.dimensions).must_equal [:product, :quarter]
93
+ _(result.to_a.count).must_equal 4
94
+ _(result.to_a.first).must_equal({product: 'Widget', quarter: 'Q1'})
95
+ end
96
+
97
+ it "removes a single dimension" do
98
+ result = sales[-:price]
99
+ _(result.dimensions).must_equal [:product, :quarter, :quantity]
100
+ _(result.to_a.count).must_equal 4
101
+ end
102
+
103
+ it "raises when mixing projection and contraction" do
104
+ _ { sales[:product, -:price] }.must_raise ArgumentError
105
+ end
106
+
107
+ it "carries formulae through contraction" do
108
+ sales[:label] = proc{|r| "#{r[:product]}-#{r[:quarter]}"}
109
+ result = sales[-:price, -:quantity]
110
+ _(result.map{|row| row[:label]}).must_equal [
111
+ 'Widget-Q1', 'Widget-Q2', 'Gadget-Q1', 'Gadget-Q2'
112
+ ]
113
+ end
114
+ end
115
+
116
+ context "selection and contraction" do
117
+ it "can use them together" do
118
+ result = sales[-:price, -:quantity, product: 'Widget']
119
+ _(result.to_a.count).must_equal 2
120
+ _(result.to_a).must_equal [
121
+ {product: 'Widget', quarter: 'Q1'},
122
+ {product: 'Widget', quarter: 'Q2'}
123
+ ]
124
+ end
125
+
126
+ it "can chain them" do
127
+ result = sales[product: 'Widget'][-:price, -:quantity]
128
+ _(result.to_a.count).must_equal 2
129
+ _(result.to_a).must_equal [
130
+ {product: 'Widget', quarter: 'Q1'},
131
+ {product: 'Widget', quarter: 'Q2'}
132
+ ]
133
+ end
134
+ end
135
+ end
136
+
137
+ describe "#[]= formulae" do
138
+ it "defines a formula" do
139
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
140
+ _(sales[:product, :quarter, :revenue].to_a).must_equal [
141
+ {product: "Widget", quarter: "Q1", revenue: 1000.0},
142
+ {product: "Widget", quarter: "Q2", revenue: 1500.0},
143
+ {product: "Gadget", quarter: "Q1", revenue: 1000.0},
144
+ {product: "Gadget", quarter: "Q2", revenue: 1500.0}
145
+ ]
146
+ end
147
+
148
+ it "composes formulae" do
149
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
150
+ sales[:cost] = proc{|r| r[:quantity] * 4.0}
151
+ sales[:profit] = proc{|r| r[:revenue] - r[:cost]}
152
+ _(sales[:product, :quarter, :profit].to_a).must_equal [
153
+ {product: "Widget", quarter: "Q1", profit: 600.0},
154
+ {product: "Widget", quarter: "Q2", profit: 900.0},
155
+ {product: "Gadget", quarter: "Q1", profit: 840.0},
156
+ {product: "Gadget", quarter: "Q2", profit: 1260.0}
157
+ ]
158
+ end
159
+
160
+ it "works with chained selection and projection" do
161
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
162
+ result = sales[product: 'Widget'][:product, :quarter, :revenue]
163
+ _(result.to_a).must_equal [
164
+ {product: "Widget", quarter: "Q1", revenue: 1000.0},
165
+ {product: "Widget", quarter: "Q2", revenue: 1500.0}
166
+ ]
167
+ end
168
+
169
+ it "works with single-call selection and projection" do
170
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
171
+ result = sales[:product, :revenue, product: 'Widget']
172
+ _(result.to_a).must_equal [
173
+ {product: "Widget", revenue: 1000.0},
174
+ {product: "Widget", revenue: 1500.0}
175
+ ]
176
+ end
177
+
178
+ it "carries formulae through selection" do
179
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
180
+ widgets = sales[product: 'Widget']
181
+ _(widgets[:revenue].to_a).must_equal [{revenue: 1000.0}, {revenue: 1500.0}]
182
+ end
183
+
184
+ it "projects formula with context dimensions" do
185
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
186
+ result = sales[:product, :quarter, :revenue]
187
+ _(result.to_a).must_equal [
188
+ {product: 'Widget', quarter: 'Q1', revenue: 1000.0},
189
+ {product: 'Widget', quarter: 'Q2', revenue: 1500.0},
190
+ {product: 'Gadget', quarter: 'Q1', revenue: 1000.0},
191
+ {product: 'Gadget', quarter: 'Q2', revenue: 1500.0}
192
+ ]
193
+ end
194
+ end
195
+
196
+ describe "#each" do
197
+ it "yields Row objects" do
198
+ rows = []
199
+ sales.each{|row| rows << row}
200
+ _(rows.first).must_be_kind_of Namo::Row
201
+ _(rows.length).must_equal 4
202
+ end
203
+
204
+ it "yields rows with access to data" do
205
+ products = sales.map{|row| row[:product]}
206
+ _(products).must_equal ['Widget', 'Widget', 'Gadget', 'Gadget']
207
+ end
208
+
209
+ it "yields rows with access to formulae" do
210
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
211
+ revenues = sales.map{|row| row[:revenue]}
212
+ _(revenues).must_equal [1000.0, 1500.0, 1000.0, 1500.0]
213
+ end
214
+
215
+ it "returns an enumerator without a block" do
216
+ _(sales.each).must_be_kind_of Enumerator
217
+ end
218
+ end
219
+
220
+ describe "Enumerable" do
221
+ it "supports reduce" do
222
+ total_quantity = sales.reduce(0){|sum, row| sum + row[:quantity]}
223
+ _(total_quantity).must_equal 350
224
+ end
225
+
226
+ it "supports reduce with selection" do
227
+ widget_quantity = sales[product: 'Widget'].reduce(0){|sum, row| sum + row[:quantity]}
228
+ _(widget_quantity).must_equal 250
229
+ end
230
+
231
+ it "supports reduce with formulae" do
232
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
233
+ total_revenue = sales.reduce(0){|sum, row| sum + row[:revenue]}
234
+ _(total_revenue).must_equal 5000.0
235
+ end
236
+
237
+ it "supports reduce with selection and formulae" do
238
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
239
+ widget_revenue = sales[product: 'Widget'].reduce(0){|sum, row| sum + row[:revenue]}
240
+ _(widget_revenue).must_equal 2500.0
241
+ end
242
+
243
+ it "supports min_by" do
244
+ cheapest = sales.min_by{|row| row[:price]}
245
+ _(cheapest[:product]).must_equal 'Widget'
246
+ end
247
+
248
+ it "supports flat_map" do
249
+ prices = sales.flat_map{|row| [row[:price]]}
250
+ _(prices).must_equal [10.0, 10.0, 25.0, 25.0]
251
+ end
252
+ end
253
+
254
+ describe "#+" do
255
+ let(:more_data) do
256
+ [
257
+ {product: 'Widget', quarter: 'Q3', price: 10.0, quantity: 200},
258
+ {product: 'Gadget', quarter: 'Q3', price: 25.0, quantity: 80}
259
+ ]
260
+ end
261
+
262
+ let(:more_sales) do
263
+ Namo.new(more_data)
264
+ end
265
+
266
+ it "concatenates rows" do
267
+ result = sales + more_sales
268
+ _(result.to_a.count).must_equal 6
269
+ _(result.to_a).must_equal(sample_data + more_data)
270
+ end
271
+
272
+ it "preserves dimensions" do
273
+ result = sales + more_sales
274
+ _(result.dimensions).must_equal [:product, :quarter, :price, :quantity]
275
+ end
276
+
277
+ it "carries formulae through from self" do
278
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
279
+ result = sales + more_sales
280
+ _(result.map{|row| row[:revenue]}).must_equal [1000.0, 1500.0, 1000.0, 1500.0, 2000.0, 2000.0]
281
+ end
282
+
283
+ it "merges formulae from other" do
284
+ more_sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
285
+ result = sales + more_sales
286
+ _(result.map{|row| row[:revenue]}).must_equal [1000.0, 1500.0, 1000.0, 1500.0, 2000.0, 2000.0]
287
+ end
288
+
289
+ it "prefers self's formulae on conflict" do
290
+ sales[:label] = proc{|r| "self: #{r[:product]}"}
291
+ more_sales[:label] = proc{|r| "other: #{r[:product]}"}
292
+ result = sales + more_sales
293
+ _(result.map{|row| row[:label]}).must_equal [
294
+ 'self: Widget', 'self: Widget', 'self: Gadget', 'self: Gadget',
295
+ 'self: Widget', 'self: Gadget'
296
+ ]
297
+ end
298
+
299
+ it "raises when dimensions differ" do
300
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
301
+ _ { sales + other }.must_raise ArgumentError
302
+ end
303
+ end
304
+
305
+ describe "#-" do
306
+ let(:to_remove) do
307
+ Namo.new([
308
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
309
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
310
+ ])
311
+ end
312
+
313
+ it "removes matching rows" do
314
+ result = sales - to_remove
315
+ _(result.to_a.count).must_equal 2
316
+ _(result.to_a).must_equal [
317
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
318
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40}
319
+ ]
320
+ end
321
+
322
+ it "preserves non-matching rows" do
323
+ other = Namo.new([{product: 'Thingo', quarter: 'Q4', price: 99.0, quantity: 1}])
324
+ result = sales - other
325
+ _(result.to_a).must_equal sample_data
326
+ end
327
+
328
+ it "carries formulae through from self" do
329
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
330
+ result = sales - to_remove
331
+ _(result.map{|row| row[:revenue]}).must_equal [1500.0, 1000.0]
332
+ end
333
+
334
+ it "raises when dimensions differ" do
335
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
336
+ _ { sales - other }.must_raise ArgumentError
337
+ end
338
+ end
339
+
340
+ describe "#&" do
341
+ let(:other) do
342
+ Namo.new([
343
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
344
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60},
345
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
346
+ ])
347
+ end
348
+
349
+ it "returns rows present in both" do
350
+ result = sales & other
351
+ _(result.to_a).must_equal [
352
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
353
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
354
+ ]
355
+ end
356
+
357
+ it "returns empty when nothing overlaps" do
358
+ other = Namo.new([{product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}])
359
+ result = sales & other
360
+ _(result.to_a).must_equal []
361
+ end
362
+
363
+ it "preserves dimensions" do
364
+ result = sales & other
365
+ _(result.dimensions).must_equal [:product, :quarter, :price, :quantity]
366
+ end
367
+
368
+ it "carries formulae through from self" do
369
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
370
+ result = sales & other
371
+ _(result.map{|row| row[:revenue]}).must_equal [1000.0, 1500.0]
372
+ end
373
+
374
+ it "raises when dimensions differ" do
375
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
376
+ _ { sales & other }.must_raise ArgumentError
377
+ end
378
+ end
379
+
380
+ describe "#|" do
381
+ let(:other) do
382
+ Namo.new([
383
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
384
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
385
+ ])
386
+ end
387
+
388
+ it "returns all rows deduplicated" do
389
+ result = sales | other
390
+ _(result.to_a).must_equal [
391
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
392
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
393
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
394
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60},
395
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
396
+ ]
397
+ end
398
+
399
+ it "preserves dimensions" do
400
+ result = sales | other
401
+ _(result.dimensions).must_equal [:product, :quarter, :price, :quantity]
402
+ end
403
+
404
+ it "merges formulae from other" do
405
+ other[:revenue] = proc{|r| r[:price] * r[:quantity]}
406
+ result = sales | other
407
+ _(result.map{|row| row[:revenue]}).must_equal [1000.0, 1500.0, 1000.0, 1500.0, 50.0]
408
+ end
409
+
410
+ it "prefers self's formulae on conflict" do
411
+ sales[:label] = proc{|r| "self: #{r[:product]}"}
412
+ other[:label] = proc{|r| "other: #{r[:product]}"}
413
+ result = sales | other
414
+ _(result.map{|row| row[:label]}).must_equal [
415
+ 'self: Widget', 'self: Widget', 'self: Gadget', 'self: Gadget', 'self: Thingo'
416
+ ]
417
+ end
418
+
419
+ it "raises when dimensions differ" do
420
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
421
+ _ { sales | other }.must_raise ArgumentError
422
+ end
423
+ end
424
+
425
+ describe "#^" do
426
+ let(:other) do
427
+ Namo.new([
428
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
429
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60},
430
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
431
+ ])
432
+ end
433
+
434
+ it "returns rows in one but not both" do
435
+ result = sales ^ other
436
+ _(result.to_a).must_equal [
437
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
438
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
439
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
440
+ ]
441
+ end
442
+
443
+ it "returns empty when both are identical" do
444
+ result = sales ^ sales
445
+ _(result.to_a).must_equal []
446
+ end
447
+
448
+ it "returns all rows when nothing overlaps" do
449
+ other = Namo.new([{product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}])
450
+ result = sales ^ other
451
+ _(result.to_a.count).must_equal 5
452
+ end
453
+
454
+ it "merges formulae with self winning on conflict" do
455
+ sales[:label] = proc{|r| "self: #{r[:product]}"}
456
+ other[:label] = proc{|r| "other: #{r[:product]}"}
457
+ result = sales ^ other
458
+ _(result.map{|row| row[:label]}).must_equal [
459
+ 'self: Widget', 'self: Gadget', 'self: Thingo'
460
+ ]
461
+ end
462
+
463
+ it "raises when dimensions differ" do
464
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
465
+ _ { sales ^ other }.must_raise ArgumentError
466
+ end
467
+ end
468
+
469
+ describe "#==" do
470
+ it "is true for same data, same order" do
471
+ a = Namo.new([{x: 1}, {x: 2}])
472
+ b = Namo.new([{x: 1}, {x: 2}])
473
+ _(a == b).must_equal true
474
+ end
475
+
476
+ it "is true for same data, different order" do
477
+ a = Namo.new([{x: 1}, {x: 2}])
478
+ b = Namo.new([{x: 2}, {x: 1}])
479
+ _(a == b).must_equal true
480
+ end
481
+
482
+ it "is false for different data" do
483
+ a = Namo.new([{x: 1}, {x: 2}])
484
+ b = Namo.new([{x: 1}, {x: 3}])
485
+ _(a == b).must_equal false
486
+ end
487
+
488
+ it "is multiset-aware: duplicates count" do
489
+ a = Namo.new([{x: 1}])
490
+ b = Namo.new([{x: 1}, {x: 1}])
491
+ _(a == b).must_equal false
492
+ end
493
+
494
+ it "is true across subclasses with same data" do
495
+ subclass = Class.new(Namo)
496
+ a = Namo.new([{x: 1}, {x: 2}])
497
+ b = subclass.new([{x: 1}, {x: 2}])
498
+ _(a == b).must_equal true
499
+ end
500
+
501
+ it "ignores formulae" do
502
+ a = Namo.new([{x: 1}, {x: 2}])
503
+ b = Namo.new([{x: 1}, {x: 2}])
504
+ b[:y] = proc{|row| row[:x] * 2}
505
+ _(a == b).must_equal true
506
+ end
507
+
508
+ it "is false against a non-Namo" do
509
+ a = Namo.new([{x: 1}, {x: 2}])
510
+ _(a == [{x: 1}, {x: 2}]).must_equal false
511
+ _(a == 'string').must_equal false
512
+ _(a == nil).must_equal false
513
+ end
514
+ end
515
+
516
+ describe "#===" do
517
+ it "is true when dimensions and formulae match, ignoring rows" do
518
+ a = Namo.new([{x: 1}])
519
+ b = Namo.new([{x: 2}, {x: 3}])
520
+ _(a === b).must_equal true
521
+ end
522
+
523
+ it "is false when formulae differ" do
524
+ a = Namo.new([{x: 1}])
525
+ b = Namo.new([{x: 1}])
526
+ b[:doubled] = proc{|row| row[:x] * 2}
527
+ _(a === b).must_equal false
528
+ end
529
+
530
+ it "is true when formulae have the same names, regardless of proc identity" do
531
+ a = Namo.new([{x: 1}])
532
+ a[:doubled] = proc{|row| row[:x] * 2}
533
+ b = Namo.new([{x: 1}])
534
+ b[:doubled] = proc{|row| row[:x] * 2}
535
+ _(a === b).must_equal true
536
+ end
537
+
538
+ it "is false when dimensions differ" do
539
+ a = Namo.new([{x: 1}])
540
+ b = Namo.new([{y: 1}])
541
+ _(a === b).must_equal false
542
+ end
543
+
544
+ it "is true when dimensions are in different order" do
545
+ a = Namo.new([{x: 1, y: 2}])
546
+ b = Namo.new([{y: 9, x: 8}])
547
+ _(a === b).must_equal true
548
+ end
549
+
550
+ it "is false for a non-Namo and does not raise" do
551
+ a = Namo.new([{x: 1}])
552
+ _(a === [{x: 1}]).must_equal false
553
+ _(a === 'string').must_equal false
554
+ _(a === nil).must_equal false
555
+ end
556
+
557
+ it "drives case statement dispatch on analytical type" do
558
+ template = Namo.new([{x: 0}])
559
+ candidate = Namo.new([{x: 5}, {x: 6}])
560
+ result = case candidate
561
+ when template; :matched
562
+ else; :not_matched
563
+ end
564
+ _(result).must_equal :matched
565
+ end
566
+ end
567
+
568
+ describe "#eql?" do
569
+ it "is true for same class, same data, no formulae" do
570
+ a = Namo.new([{x: 1}, {x: 2}])
571
+ b = Namo.new([{x: 1}, {x: 2}])
572
+ _(a.eql?(b)).must_equal true
573
+ end
574
+
575
+ it "is true for same class, same data, different order" do
576
+ a = Namo.new([{x: 1}, {x: 2}])
577
+ b = Namo.new([{x: 2}, {x: 1}])
578
+ _(a.eql?(b)).must_equal true
579
+ end
580
+
581
+ it "is true when formula names match, regardless of proc identity" do
582
+ a = Namo.new([{x: 1}, {x: 2}])
583
+ a[:y] = proc{|row| row[:x] * 2}
584
+ b = Namo.new([{x: 1}, {x: 2}])
585
+ b[:y] = proc{|row| row[:x] * 2}
586
+ _(a.eql?(b)).must_equal true
587
+ end
588
+
589
+ it "is false when formula names differ" do
590
+ a = Namo.new([{x: 1}, {x: 2}])
591
+ a[:doubled] = proc{|row| row[:x] * 2}
592
+ b = Namo.new([{x: 1}, {x: 2}])
593
+ b[:tripled] = proc{|row| row[:x] * 3}
594
+ _(a.eql?(b)).must_equal false
595
+ end
596
+
597
+ it "is false across different classes" do
598
+ subclass = Class.new(Namo)
599
+ a = Namo.new([{x: 1}, {x: 2}])
600
+ b = subclass.new([{x: 1}, {x: 2}])
601
+ _(a.eql?(b)).must_equal false
602
+ end
603
+ end
604
+
605
+ describe "#hash" do
606
+ it "is equal for set-equal Namos" do
607
+ a = Namo.new([{x: 1}, {x: 2}])
608
+ b = Namo.new([{x: 2}, {x: 1}])
609
+ _(a.hash).must_equal b.hash
610
+ end
611
+
612
+ it "differs when formula names differ" do
613
+ a = Namo.new([{x: 1}, {x: 2}])
614
+ b = Namo.new([{x: 1}, {x: 2}])
615
+ b[:y] = proc{|row| row[:x] * 2}
616
+ _(a.hash).wont_equal b.hash
617
+ end
618
+
619
+ it "is equal when formula names match, regardless of proc identity" do
620
+ a = Namo.new([{x: 1}, {x: 2}])
621
+ a[:y] = proc{|row| row[:x] * 2}
622
+ b = Namo.new([{x: 1}, {x: 2}])
623
+ b[:y] = proc{|row| row[:x] * 2}
624
+ _(a.hash).must_equal b.hash
625
+ end
626
+
627
+ it "differs across classes" do
628
+ subclass = Class.new(Namo)
629
+ a = Namo.new([{x: 1}, {x: 2}])
630
+ b = subclass.new([{x: 1}, {x: 2}])
631
+ _(a.hash).wont_equal b.hash
632
+ end
633
+
634
+ it "makes Namos usable as Hash keys" do
635
+ a = Namo.new([{x: 1}, {x: 2}])
636
+ b = Namo.new([{x: 2}, {x: 1}])
637
+ h = {a => 'first'}
638
+ _(h[b]).must_equal 'first'
639
+ end
640
+ end
641
+
642
+ describe "#<, #<=, #>, #>=" do
643
+ let(:small) { Namo.new([{x: 1}, {x: 2}]) }
644
+ let(:large) { Namo.new([{x: 1}, {x: 2}, {x: 3}]) }
645
+ let(:disjoint) { Namo.new([{x: 4}, {x: 5}]) }
646
+
647
+ it "recognises proper subset" do
648
+ _(small < large).must_equal true
649
+ _(small <= large).must_equal true
650
+ _(large > small).must_equal true
651
+ _(large >= small).must_equal true
652
+ end
653
+
654
+ it "treats equal sets as <= and >= but not < or >" do
655
+ copy = Namo.new([{x: 2}, {x: 1}])
656
+ _(small <= copy).must_equal true
657
+ _(small >= copy).must_equal true
658
+ _(small < copy).must_equal false
659
+ _(small > copy).must_equal false
660
+ end
661
+
662
+ it "treats disjoint sets as neither subset nor superset" do
663
+ _(small <= disjoint).must_equal false
664
+ _(small >= disjoint).must_equal false
665
+ _(small < disjoint).must_equal false
666
+ _(small > disjoint).must_equal false
667
+ end
668
+
669
+ it "is multiset-aware: a single row is a proper subset of two of the same row" do
670
+ one = Namo.new([{x: 1}])
671
+ two = Namo.new([{x: 1}, {x: 1}])
672
+ _(one < two).must_equal true
673
+ _(one <= two).must_equal true
674
+ _(two <= one).must_equal false
675
+ _(two < one).must_equal false
676
+ end
677
+
678
+ it "raises ArgumentError on mismatched dimensions" do
679
+ other = Namo.new([{y: 1}])
680
+ _ { small < other }.must_raise ArgumentError
681
+ _ { small <= other }.must_raise ArgumentError
682
+ _ { small > other }.must_raise ArgumentError
683
+ _ { small >= other }.must_raise ArgumentError
684
+ end
685
+
686
+ it "raises TypeError on non-Namo" do
687
+ _ { small < [{x: 1}] }.must_raise TypeError
688
+ _ { small <= 'string' }.must_raise TypeError
689
+ _ { small > nil }.must_raise TypeError
690
+ _ { small >= 42 }.must_raise TypeError
691
+ end
692
+ end
693
+
694
+ describe "#equal?" do
695
+ it "is false for distinct objects" do
696
+ a = Namo.new([{x: 1}])
697
+ b = Namo.new([{x: 1}])
698
+ _(a.equal?(b)).must_equal false
699
+ end
700
+
701
+ it "is true for the same object" do
702
+ a = Namo.new([{x: 1}])
703
+ _(a.equal?(a)).must_equal true
704
+ end
705
+ end
706
+
707
+ describe "dimension-mismatch error message" do
708
+ it "names both dimension lists" do
709
+ a = Namo.new([{x: 1}])
710
+ b = Namo.new([{y: 1}])
711
+ err = _ { a + b }.must_raise ArgumentError
712
+ _(err.message).must_match(/dimensions don't match/)
713
+ _(err.message).must_match(/\[:x\]/)
714
+ _(err.message).must_match(/\[:y\]/)
715
+ end
716
+ end
717
+
718
+ describe "non-Namo comparison error message" do
719
+ it "names the offending class" do
720
+ a = Namo.new([{x: 1}])
721
+ err = _ { a < 'string' }.must_raise TypeError
722
+ _(err.message).must_match(/can't compare Namo with/)
723
+ _(err.message).must_match(/String/)
724
+ end
725
+ end
726
+
727
+ describe "non-Namo set operation error message" do
728
+ it "raises TypeError on non-Namo for #+, #-, #&, #|, #^" do
729
+ a = Namo.new([{x: 1}])
730
+ _ { a + [{x: 1}] }.must_raise TypeError
731
+ _ { a - 'string' }.must_raise TypeError
732
+ _ { a & nil }.must_raise TypeError
733
+ _ { a | 42 }.must_raise TypeError
734
+ _ { a ^ :symbol }.must_raise TypeError
735
+ end
736
+
737
+ it "names the offending class" do
738
+ a = Namo.new([{x: 1}])
739
+ err = _ { a + 'string' }.must_raise TypeError
740
+ _(err.message).must_match(/can't compare Namo with/)
741
+ _(err.message).must_match(/String/)
742
+ end
743
+ end
744
+
745
+ describe "#to_a" do
746
+ it "returns the data as an array of hashes" do
747
+ _(sales.to_a).must_equal sample_data
748
+ end
749
+ end
750
+ end