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
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
require 'minitest/autorun'
|
|
2
|
+
require 'minitest-spec-context'
|
|
3
|
+
|
|
4
|
+
require_relative '../../lib/namo'
|
|
5
|
+
|
|
6
|
+
class Array
|
|
7
|
+
def mean
|
|
8
|
+
sum.to_f / size
|
|
9
|
+
end
|
|
10
|
+
end unless [].respond_to?(:mean)
|
|
11
|
+
|
|
12
|
+
class SubAssembly < Namo; end
|
|
13
|
+
|
|
14
|
+
class Car < Namo::Collection
|
|
15
|
+
def summary(dimension, by: :assembly, reducer: :sum)
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def detail(by: :assembly)
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
describe Namo::Collection do
|
|
25
|
+
let(:powertrain) do
|
|
26
|
+
SubAssembly.new(name: :powertrain, data: [
|
|
27
|
+
{component: 'engine', weight: 200, cost: 50000},
|
|
28
|
+
{component: 'gearbox', weight: 80, cost: 20000},
|
|
29
|
+
])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
let(:chassis) do
|
|
33
|
+
SubAssembly.new(name: :chassis, data: [{component: 'frame', weight: 150, cost: 30000}])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
let(:body) do
|
|
37
|
+
SubAssembly.new(name: :body, data: [{component: 'panels', weight: 60, cost: 15000}])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
let(:wheels) do
|
|
41
|
+
SubAssembly.new(name: :wheels, data: [{component: 'tyres', weight: 40, cost: 8000}])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
let(:collection) do
|
|
45
|
+
Namo::Collection.new.tap{|c| c << [powertrain, chassis, body]}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
let(:component_pricing) do
|
|
49
|
+
Module.new do
|
|
50
|
+
include Namo::Formulary
|
|
51
|
+
def cost_per_kg(row); row[:cost] / row[:weight]; end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
let(:fleet_metrics) do
|
|
56
|
+
Module.new do
|
|
57
|
+
include Namo::Formulary
|
|
58
|
+
def member_count(row, namo_collection); namo_collection.members.size; end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
describe "construction" do
|
|
63
|
+
it "starts with empty members" do
|
|
64
|
+
_(Namo::Collection.new.members).must_equal []
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe "lazy detail materialisation" do
|
|
69
|
+
it "materialises detail on a bare row-operation without a prior as_detail" do
|
|
70
|
+
_(collection.values(:weight)).must_equal [200, 80, 150, 60]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "supports selection against the materialised detail" do
|
|
74
|
+
_(collection[component: 'engine'].values(:component)).must_equal ['engine']
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "reflects a newly added member on the next operation" do
|
|
78
|
+
before = collection.values(:weight)
|
|
79
|
+
collection << wheels
|
|
80
|
+
_(collection.values(:weight)).must_equal before + [40]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
describe "#<<" do
|
|
85
|
+
it "adds a member" do
|
|
86
|
+
collection = Namo::Collection.new
|
|
87
|
+
collection << powertrain
|
|
88
|
+
_(collection.members.size).must_equal 1
|
|
89
|
+
_(collection.find(:powertrain)).must_be_same_as powertrain
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "replaces a member with a colliding name (last-write-wins)" do
|
|
93
|
+
collection = Namo::Collection.new
|
|
94
|
+
collection << powertrain
|
|
95
|
+
replacement = SubAssembly.new(name: :powertrain, data: [{component: 'hybrid', weight: 250, cost: 70000}])
|
|
96
|
+
collection << replacement
|
|
97
|
+
_(collection.members.size).must_equal 1
|
|
98
|
+
_(collection.find(:powertrain)).must_be_same_as replacement
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "adds each member from an array" do
|
|
102
|
+
collection = Namo::Collection.new
|
|
103
|
+
collection << [powertrain, chassis]
|
|
104
|
+
_(collection.members.size).must_equal 2
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "appends an unnamed member, which is unfindable by name" do
|
|
108
|
+
collection = Namo::Collection.new
|
|
109
|
+
collection << SubAssembly.new(data: [{component: 'misc', weight: 5, cost: 100}])
|
|
110
|
+
collection << SubAssembly.new(data: [{component: 'other', weight: 6, cost: 200}])
|
|
111
|
+
_(collection.members.size).must_equal 2
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "returns self" do
|
|
115
|
+
collection = Namo::Collection.new
|
|
116
|
+
_(collection << powertrain).must_be_same_as collection
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "attaches a formulary whose row-scoped method resolves over the detail rows" do
|
|
120
|
+
collection << component_pricing
|
|
121
|
+
_(collection.values(:cost_per_kg)).must_equal [250, 250, 200, 250]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "attaches a formulary whose collection-scoped method reaches members" do
|
|
125
|
+
collection << fleet_metrics
|
|
126
|
+
_(collection.values(:member_count)).must_equal [3, 3, 3, 3]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "raises an ArgumentError on a loose Hash, redirecting to member-add" do
|
|
130
|
+
error = _(proc{collection << {component: 'bolt', weight: 1, cost: 5}}).must_raise ArgumentError
|
|
131
|
+
_(error.message).must_match(/member/)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "raises an ArgumentError on a loose Row, redirecting to member-add" do
|
|
135
|
+
row = Namo.new([{component: 'bolt', weight: 1, cost: 5}]).entries.first
|
|
136
|
+
error = _(proc{collection << row}).must_raise ArgumentError
|
|
137
|
+
_(error.message).must_match(/member/)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "chains member-adds and a formulary attach" do
|
|
141
|
+
collection = Namo::Collection.new
|
|
142
|
+
collection << powertrain << chassis << component_pricing
|
|
143
|
+
_(collection.members.size).must_equal 2
|
|
144
|
+
_(collection.values(:cost_per_kg)).must_equal [250, 250, 200]
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
describe "#detach" do
|
|
149
|
+
it "detaches a formulary attached to the collection, so the detail rows no longer resolve its name" do
|
|
150
|
+
collection << component_pricing
|
|
151
|
+
_(collection.values(:cost_per_kg)).must_equal [250, 250, 200, 250]
|
|
152
|
+
collection.detach(component_pricing)
|
|
153
|
+
_(collection.derived_dimensions).wont_include :cost_per_kg
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
describe "#find" do
|
|
158
|
+
it "returns the member with the given name" do
|
|
159
|
+
_(collection.find(:chassis)).must_be_same_as chassis
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "returns nil for an absent name" do
|
|
163
|
+
_(collection.find(:engine)).must_be_nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "returns nil for find(nil)" do
|
|
167
|
+
_(collection.find(nil)).must_be_nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it "never matches an unnamed member" do
|
|
171
|
+
collection = Namo::Collection.new
|
|
172
|
+
collection << SubAssembly.new(data: [{component: 'misc', weight: 5, cost: 100}])
|
|
173
|
+
_(collection.find(nil)).must_be_nil
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
describe "#summary" do
|
|
178
|
+
it "reduces each member to a labelled row" do
|
|
179
|
+
summary = collection.summary(:weight)
|
|
180
|
+
_(summary.values(:member)).must_equal [:powertrain, :chassis, :body]
|
|
181
|
+
_(summary.values(:weight)).must_equal [280, 150, 60]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it "labels with a custom by dimension, carrying the reduced value alongside" do
|
|
185
|
+
summary = collection.summary(:weight, by: :assembly)
|
|
186
|
+
_(summary.values(:assembly)).must_equal [:powertrain, :chassis, :body]
|
|
187
|
+
_(summary.values(:weight)).must_equal [280, 150, 60]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "reduces with a custom reducer" do
|
|
191
|
+
summary = collection.summary(:weight, reducer: :mean)
|
|
192
|
+
_(summary.values(:weight)).must_equal [140.0, 150.0, 60.0]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it "is non-mutating — leaves the collection's data untouched" do
|
|
196
|
+
collection.summary(:weight)
|
|
197
|
+
_(collection.values(:weight)).must_equal [200, 80, 150, 60]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it "with a block returns one row per member, the block's hash merged with the member label" do
|
|
201
|
+
result = collection.summary do |member|
|
|
202
|
+
{heaviest: member.max_by{|row| row[:weight]}[:component]}
|
|
203
|
+
end
|
|
204
|
+
_(result.values(:member)).must_equal [:powertrain, :chassis, :body]
|
|
205
|
+
_(result.values(:heaviest)).must_equal ['engine', 'frame', 'panels']
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "with a block, the member label wins over a by key the block returns" do
|
|
209
|
+
result = collection.summary(by: :assembly) do |member|
|
|
210
|
+
{assembly: :overridden, total: member.values(:weight).sum}
|
|
211
|
+
end
|
|
212
|
+
_(result.values(:assembly)).must_equal [:powertrain, :chassis, :body]
|
|
213
|
+
_(result.values(:total)).must_equal [280, 150, 60]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it "raises an ArgumentError when given neither a dimension nor a block" do
|
|
217
|
+
error = _(proc{collection.summary}).must_raise ArgumentError
|
|
218
|
+
_(error.message).must_match(/dimension or a block/)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
it "yields one row per member (map), where detail yields all rows per member (flat_map)" do
|
|
222
|
+
_(collection.summary(:weight).data.size).must_equal collection.members.size
|
|
223
|
+
_(collection.detail.data.size).must_equal collection.members.sum{|member| member.data.size}
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
describe "#detail" do
|
|
228
|
+
it "returns a plain Namo" do
|
|
229
|
+
_(collection.detail).must_be_instance_of Namo
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it "unions the members' rows, injecting the by dimension when absent" do
|
|
233
|
+
detail = collection.detail
|
|
234
|
+
_(detail.values(:member)).must_equal [:powertrain, :powertrain, :chassis, :body]
|
|
235
|
+
_(detail.values(:weight)).must_equal [200, 80, 150, 60]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it "does not inject when the by dimension is already present" do
|
|
239
|
+
collection = Namo::Collection.new
|
|
240
|
+
collection << SubAssembly.new(name: :ignored, data: [{member: :preexisting, weight: 5}])
|
|
241
|
+
_(collection.detail.values(:member)).must_equal [:preexisting]
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it "is non-mutating — leaves the collection's data untouched" do
|
|
245
|
+
collection.detail(by: :assembly)
|
|
246
|
+
_(collection.dimensions).wont_include :assembly
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it "takes the by label positionally or by keyword, positional winning" do
|
|
250
|
+
_(collection.detail(:assembly).values(:assembly)).must_equal [:powertrain, :powertrain, :chassis, :body]
|
|
251
|
+
_(collection.detail(by: :assembly).values(:assembly)).must_equal [:powertrain, :powertrain, :chassis, :body]
|
|
252
|
+
_(collection.detail(:assembly, by: :ignored).values(:assembly)).must_equal [:powertrain, :powertrain, :chassis, :body]
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
describe "live recomputation (no memoisation in 1.x)" do
|
|
257
|
+
it "reflects a mutation on the next detail call" do
|
|
258
|
+
before = collection.detail.values(:weight).size
|
|
259
|
+
collection << wheels
|
|
260
|
+
_(collection.detail.values(:weight).size).must_equal before + 1
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it "reflects a mutation on the next summary call" do
|
|
264
|
+
collection << wheels
|
|
265
|
+
_(collection.summary(:weight).values(:member)).must_equal [:powertrain, :chassis, :body, :wheels]
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
describe "#as_summary / #as_detail" do
|
|
270
|
+
it "as_summary sets the data to the summary view and returns self" do
|
|
271
|
+
result = collection.as_summary(:weight)
|
|
272
|
+
_(result).must_be_same_as collection
|
|
273
|
+
_(collection.values(:weight)).must_equal [280, 150, 60]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
it "as_detail sets the data to the detail view and returns self" do
|
|
277
|
+
collection.as_summary(:weight)
|
|
278
|
+
result = collection.as_detail
|
|
279
|
+
_(result).must_be_same_as collection
|
|
280
|
+
_(collection.values(:weight)).must_equal [200, 80, 150, 60]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
it "exposes the summary's columns in dimensions immediately after as_summary" do
|
|
284
|
+
collection.as_summary(:weight)
|
|
285
|
+
_(collection.dimensions.sort).must_equal [:member, :weight].sort
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
it "as_summary with a block sets the data to the block summary and returns self" do
|
|
289
|
+
result = collection.as_summary(by: :assembly){|member| {count: member.values(:weight).size}}
|
|
290
|
+
_(result).must_be_same_as collection
|
|
291
|
+
_(collection.values(:assembly)).must_equal [:powertrain, :chassis, :body]
|
|
292
|
+
_(collection.values(:count)).must_equal [2, 1, 1]
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it "as_detail takes the by label positionally or by keyword, positional winning" do
|
|
296
|
+
positional = Namo::Collection.new.tap{|c| c << [powertrain, chassis]}.as_detail(:assembly)
|
|
297
|
+
keyword = Namo::Collection.new.tap{|c| c << [powertrain, chassis]}.as_detail(by: :assembly)
|
|
298
|
+
_(positional.values(:assembly)).must_equal [:powertrain, :powertrain, :chassis]
|
|
299
|
+
_(keyword.values(:assembly)).must_equal positional.values(:assembly)
|
|
300
|
+
_(Namo::Collection.new.tap{|c| c << powertrain}.as_detail(:assembly, by: :ignored).values(:assembly)).must_equal [:powertrain, :powertrain]
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
describe "as_* view lifetime (rebuild-on-<<: persists until the next <<)" do
|
|
305
|
+
it "keeps the summary view across a bare row-operation" do
|
|
306
|
+
collection.as_summary(:weight)
|
|
307
|
+
_(collection.values(:weight)).must_equal [280, 150, 60]
|
|
308
|
+
_(collection[member: :powertrain].values(:weight)).must_equal [280]
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
it "re-materialises detail on the next <<" do
|
|
312
|
+
collection.as_summary(:weight)
|
|
313
|
+
collection << wheels
|
|
314
|
+
_(collection.values(:weight)).must_equal [200, 80, 150, 60, 40]
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
describe "assembly round-trip" do
|
|
319
|
+
it "injects :assembly via as_detail and retains it through a later << and as_detail" do
|
|
320
|
+
collection.as_detail(:assembly)
|
|
321
|
+
_(collection.dimensions).must_include :assembly
|
|
322
|
+
_(collection.coordinates(:assembly)).must_equal [:powertrain, :chassis, :body]
|
|
323
|
+
collection << wheels
|
|
324
|
+
collection.as_detail(:assembly)
|
|
325
|
+
_(collection.dimensions).must_include :assembly
|
|
326
|
+
_(collection.coordinates(:assembly)).must_equal [:powertrain, :chassis, :body, :wheels]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
it "removes :assembly only by explicit contraction" do
|
|
330
|
+
collection.as_detail(:assembly)
|
|
331
|
+
contracted = collection[-:assembly]
|
|
332
|
+
_(contracted.dimensions).wont_include :assembly
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
describe "subclass with a default by:" do
|
|
337
|
+
let(:car) do
|
|
338
|
+
Car.new.tap{|c| c << [powertrain, chassis, body]}
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
it "uses the subclass default :assembly for summary" do
|
|
342
|
+
_(car.summary(:weight).values(:assembly)).must_equal [:powertrain, :chassis, :body]
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
it "uses the subclass default :assembly for detail" do
|
|
346
|
+
_(car.detail.values(:assembly)).must_equal [:powertrain, :powertrain, :chassis, :body]
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# 0.18.0 has an inherited row-operation read the detail view and "behave as the
|
|
351
|
+
# detail", and the detail is a Namo. Built as a Collection the result was one
|
|
352
|
+
# with no members: it answered summary with an empty Namo rather than saying it
|
|
353
|
+
# had no summary to give, and rendered as [] beside a row count.
|
|
354
|
+
describe "the class of a derived result" do
|
|
355
|
+
def grouped
|
|
356
|
+
Namo.new([
|
|
357
|
+
{station: "Melbourne", month: "2025-01", high_temp: 26.4},
|
|
358
|
+
{station: "Perth", month: "2025-01", high_temp: 32.0},
|
|
359
|
+
{station: "Perth", month: "2025-02", high_temp: 29.5}]).group_by(:month)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
it "is a Namo for selection and projection" do
|
|
363
|
+
_(grouped[station: "Perth"]).must_be_instance_of Namo
|
|
364
|
+
_(grouped[:station, :high_temp]).must_be_instance_of Namo
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
it "is a Namo for the subset Enumerable methods" do
|
|
368
|
+
collection = grouped
|
|
369
|
+
_(collection.select{|row| row[:station] == "Perth"}).must_be_instance_of Namo
|
|
370
|
+
_(collection.sort_by{|row| row[:high_temp]}).must_be_instance_of Namo
|
|
371
|
+
_(collection.first(2)).must_be_instance_of Namo
|
|
372
|
+
_(collection.uniq).must_be_instance_of Namo
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
it "is a Namo for the set and composition operators" do
|
|
376
|
+
collection = grouped
|
|
377
|
+
_(collection + collection).must_be_instance_of Namo
|
|
378
|
+
_(collection - collection).must_be_instance_of Namo
|
|
379
|
+
_(collection & collection).must_be_instance_of Namo
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
it "carries the rows the operation selected" do
|
|
383
|
+
_(grouped[station: "Perth"].values(:high_temp)).must_equal [32.0, 29.5]
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# The point of it: a summary of no members is not an empty summary.
|
|
387
|
+
it "has no summary to give, rather than an empty one" do
|
|
388
|
+
_{grouped[station: "Perth"].summary(:high_temp)}.must_raise NoMethodError
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
it "leaves a subclass its own kind" do
|
|
392
|
+
priced = Class.new(Namo)
|
|
393
|
+
instance = priced.new([{a: 1}, {a: 2}])
|
|
394
|
+
_(instance.select{|row| row[:a] > 1}).must_be_instance_of priced
|
|
395
|
+
_(instance[:a]).must_be_instance_of priced
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
it "leaves group_by returning a Collection" do
|
|
399
|
+
_(grouped).must_be_instance_of Namo::Collection
|
|
400
|
+
_(grouped.members.length).must_equal 2
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
describe "the detail view and its formulae" do
|
|
405
|
+
def readings
|
|
406
|
+
namo = Namo.new([
|
|
407
|
+
{station: "Melbourne", month: "2025-01", high_temp: 26.4, mean_temp: 25.9},
|
|
408
|
+
{station: "Perth", month: "2025-01", high_temp: 32.0, mean_temp: 30.0}])
|
|
409
|
+
namo[:anomaly] = proc{|row| (row[:high_temp] - row[:mean_temp]).round(1)}
|
|
410
|
+
namo
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
it "carries the members' formulae, the view being the members' rows" do
|
|
414
|
+
_(readings.group_by(:station).detail(:station).derived_dimensions).must_equal [:anomaly]
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
it "answers a derived dimension through the detail view" do
|
|
418
|
+
_(readings.group_by(:station).detail(:station).values(:anomaly)).must_equal [0.5, 2.0]
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# == compares row multisets and cannot see a formula go missing, so it called
|
|
422
|
+
# the partition exact while the derived dimensions were being dropped. === is
|
|
423
|
+
# the operator which compares the formula names too.
|
|
424
|
+
it "inverts the partition exactly, formulae and all" do
|
|
425
|
+
source = readings
|
|
426
|
+
_(source.group_by(:station).as_detail(:station)).must_be :===, source
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
it "folds the members' formulae, a later member winning the name" do
|
|
430
|
+
first = Namo.new([{a: 1}], name: :first)
|
|
431
|
+
first[:b] = proc{|row| :from_first}
|
|
432
|
+
second = Namo.new([{a: 2}], name: :second)
|
|
433
|
+
second[:b] = proc{|row| :from_second}
|
|
434
|
+
collection = Namo::Collection.new
|
|
435
|
+
collection << first << second
|
|
436
|
+
_(collection.detail.values(:b)).must_equal [:from_second, :from_second]
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# A summary's rows are reductions, holding neither the dimensions the members'
|
|
440
|
+
# formulae read nor the rows they read them from, so it carries none.
|
|
441
|
+
it "leaves the summary without them" do
|
|
442
|
+
_(readings.group_by(:station).summary(:high_temp).derived_dimensions).must_equal []
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
describe "#inspect" do
|
|
447
|
+
def collection_of(members, rows_each)
|
|
448
|
+
rows = (1..members).flat_map{|member| (1..rows_each).map{|row| {group: "g#{member}", n: row}}}
|
|
449
|
+
Namo.new(rows).group_by(:group)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
it "renders each member nested, under its name" do
|
|
453
|
+
collection = Namo::Collection.new
|
|
454
|
+
collection << Namo.new([{a: 1}], name: :first) << Namo.new([{a: 2}], name: :second)
|
|
455
|
+
_(collection.inspect).must_equal <<~INSPECT.chomp
|
|
456
|
+
#<Namo::Collection [
|
|
457
|
+
:first => [{a: 1}],
|
|
458
|
+
:second => [{a: 2}]
|
|
459
|
+
] 2 rows>
|
|
460
|
+
INSPECT
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
it "renders an empty Collection" do
|
|
464
|
+
_(Namo::Collection.new.inspect).must_equal "#<Namo::Collection [] 0 rows>"
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# The budget is INSPECTED_ROWS spread across the members shown, so the output
|
|
468
|
+
# is bounded by what was asked for rather than by how the data is shaped.
|
|
469
|
+
it "gives a lone member the whole row budget" do
|
|
470
|
+
rendered = collection_of(1, 50).inspect
|
|
471
|
+
_(rendered.scan(/\{group:/).length).must_equal Namo::INSPECTED_ROWS
|
|
472
|
+
_(rendered).must_include "... 40 more rows"
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
it "gives five members two rows apiece" do
|
|
476
|
+
rendered = collection_of(5, 9).inspect
|
|
477
|
+
_(rendered.scan(/\{group:/).length).must_equal Namo::INSPECTED_ROWS
|
|
478
|
+
_(rendered).must_include "... 7 more rows"
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
it "gives ten members one row apiece, on one line each" do
|
|
482
|
+
rendered = collection_of(10, 4).inspect
|
|
483
|
+
_(rendered.scan(/\{group:/).length).must_equal Namo::INSPECTED_ROWS
|
|
484
|
+
_(rendered).must_include %q{"g1" => [{group: "g1", n: 1}, ... 3 more rows]}
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
it "elides the members beyond the budget, with a count" do
|
|
488
|
+
rendered = collection_of(2641, 130).inspect
|
|
489
|
+
_(rendered).must_include "... 2631 more members"
|
|
490
|
+
_(rendered.lines.length).must_equal 13
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# 2,641 members emitted 22,704 characters on a single line before the budget.
|
|
494
|
+
it "stays bounded as the members multiply" do
|
|
495
|
+
_(collection_of(2641, 130).inspect.length).must_be :<, collection_of(10, 130).inspect.length * 2
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
it "reports the total row count of the detail view" do
|
|
499
|
+
_(collection_of(3, 7).inspect).must_include "] 21 rows>"
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Where the two rules meet: 0.31.2 names only what the rendered rows do not
|
|
503
|
+
# carry. A member's formula is carried, since its rows are what is rendered; a
|
|
504
|
+
# Collection's own applies to the data view, which the nested form never shows,
|
|
505
|
+
# so naming it is the only way to learn it is there.
|
|
506
|
+
it "names its own derived dimensions while showing its members' inline" do
|
|
507
|
+
member = Namo.new([{part: "block", weight: 90}], name: :powertrain)
|
|
508
|
+
member[:light] = proc{|row| row[:weight] < 50}
|
|
509
|
+
collection = Namo::Collection.new
|
|
510
|
+
collection << member
|
|
511
|
+
collection[:heavy] = proc{|row| row[:weight] > 50}
|
|
512
|
+
_(collection.derived_dimensions).must_equal [:heavy]
|
|
513
|
+
_(member.derived_dimensions).must_equal [:light]
|
|
514
|
+
_(collection.inspect).must_include "{part: \"block\", weight: 90, light: false}"
|
|
515
|
+
_(collection.inspect).must_include "] 1 rows derived: [:heavy]>"
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Nested, a member must say what it says of itself: a formula it cannot
|
|
519
|
+
# materialise is named in its own inspect, and vanishing here would make the
|
|
520
|
+
# nested rendering quieter than the parts it is made of.
|
|
521
|
+
it "carries a member's own suffix for what that member cannot materialise" do
|
|
522
|
+
member = Namo.new([{part: "block", weight: 90}], name: :powertrain)
|
|
523
|
+
member[:light] = proc{|row| row[:weight] < 50}
|
|
524
|
+
member[:scaled] = proc{|row, namo, factor| row[:weight] * factor}
|
|
525
|
+
collection = Namo::Collection.new
|
|
526
|
+
collection << member
|
|
527
|
+
_(member.inspect).must_include "derived: [:scaled]"
|
|
528
|
+
_(collection.inspect).must_include ":powertrain => [{part: \"block\", weight: 90, light: false}] derived: [:scaled]"
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
it "carries no suffix when only its members hold formulae" do
|
|
532
|
+
member = Namo.new([{part: "block", weight: 90}], name: :powertrain)
|
|
533
|
+
member[:light] = proc{|row| row[:weight] < 50}
|
|
534
|
+
collection = Namo::Collection.new
|
|
535
|
+
collection << member
|
|
536
|
+
_(collection.inspect).wont_include "derived:"
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
it "renders a member's derived dimensions among its stored ones" do
|
|
540
|
+
member = Namo.new([{a: 1}], name: :first)
|
|
541
|
+
member[:b] = proc{|row| row[:a] + 1}
|
|
542
|
+
collection = Namo::Collection.new
|
|
543
|
+
collection << member
|
|
544
|
+
_(collection.inspect).must_include "{a: 1, b: 2}"
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
end
|