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.
@@ -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