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.
- checksums.yaml +7 -0
- data/CHANGELOG +566 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +1268 -0
- data/Rakefile +42 -0
- data/lib/Namo/Collection.rb +130 -0
- data/lib/Namo/Enumerable.rb +83 -0
- data/lib/Namo/Formulae.rb +137 -0
- data/lib/Namo/Formulary.rb +6 -0
- data/lib/Namo/NegatedDimension.rb +14 -0
- data/lib/Namo/Row.rb +93 -0
- data/lib/Namo/VERSION.rb +6 -0
- data/lib/Symbol.rb +8 -0
- data/lib/namo.rb +433 -0
- data/namo.rb.gemspec +42 -0
- data/test/Namo/Collection_test.rb +548 -0
- data/test/Namo/Formulae_test.rb +458 -0
- data/test/Namo/NegatedDimension_test.rb +13 -0
- data/test/Namo/Row_test.rb +487 -0
- data/test/Symbol_test.rb +16 -0
- data/test/console_test.rb +44 -0
- data/test/demo_test.rb +86 -0
- data/test/namo_test.rb +3328 -0
- data/test/sizing_test.rb +40 -0
- metadata +107 -0
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
|