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.
- checksums.yaml +4 -4
- data/CHANGELOG +25 -0
- data/README.md +164 -0
- data/Rakefile +33 -0
- data/lib/Namo/VERSION.rb +1 -1
- data/lib/namo.rb +98 -9
- data/namo.gemspec +0 -1
- data/test/namo_test.rb +750 -0
- metadata +3 -3
- data/test/Namo_test.rb +0 -345
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
|