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,487 @@
|
|
|
1
|
+
require 'date'
|
|
2
|
+
require 'minitest/autorun'
|
|
3
|
+
require 'minitest-spec-context'
|
|
4
|
+
|
|
5
|
+
require_relative '../../lib/namo'
|
|
6
|
+
|
|
7
|
+
describe Namo::Row do
|
|
8
|
+
let(:row_data) do
|
|
9
|
+
{product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
let(:formulae) do
|
|
13
|
+
Namo::Formulae.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
let(:row) do
|
|
17
|
+
Namo::Row.new(row_data, formulae)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
describe "#[]" do
|
|
21
|
+
it "returns raw data by dimension name" do
|
|
22
|
+
_(row[:product]).must_equal 'Widget'
|
|
23
|
+
_(row[:price]).must_equal 10.0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "returns nil for missing dimensions" do
|
|
27
|
+
_(row[:missing]).must_be_nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "resolves formulae over raw data" do
|
|
31
|
+
formulae[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
32
|
+
_(row[:revenue]).must_equal 1000.0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "composes formulae" do
|
|
36
|
+
formulae[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
37
|
+
formulae[:cost] = proc{|r| r[:quantity] * 4.0}
|
|
38
|
+
formulae[:profit] = proc{|r| r[:revenue] - r[:cost]}
|
|
39
|
+
_(row[:profit]).must_equal 600.0
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe "constructor" do
|
|
44
|
+
it "constructs from the two-argument form (namo defaults nil)" do
|
|
45
|
+
_(Namo::Row.new(row_data, formulae)).must_be_kind_of Namo::Row
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "accepts a third namo argument" do
|
|
49
|
+
namo = Namo.new([row_data])
|
|
50
|
+
_(Namo::Row.new(row_data, formulae, namo)).must_be_kind_of Namo::Row
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe "#[] arity dispatch" do
|
|
55
|
+
it "calls an arity-1 formula with the Row only" do
|
|
56
|
+
seen = nil
|
|
57
|
+
formulae[:dim] = ->(r){seen = r; 1}
|
|
58
|
+
row[:dim]
|
|
59
|
+
_(seen).must_be_same_as row
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it "calls an arity-2 formula with the Row and the yielding Namo" do
|
|
63
|
+
namo = Namo.new([row_data])
|
|
64
|
+
row = Namo::Row.new(row_data, formulae, namo)
|
|
65
|
+
seen_row = nil
|
|
66
|
+
seen_namo = nil
|
|
67
|
+
formulae[:dim] = ->(r, n){seen_row = r; seen_namo = n; 1}
|
|
68
|
+
row[:dim]
|
|
69
|
+
_(seen_row).must_be_same_as row
|
|
70
|
+
_(seen_namo.equal?(namo)).must_equal true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "takes the one-arity path for an arity-0 proc" do
|
|
74
|
+
formulae[:dim] = proc{42}
|
|
75
|
+
_(row[:dim]).must_equal 42
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "takes the one-arity path for a negative-arity proc" do
|
|
79
|
+
seen_rest = nil
|
|
80
|
+
formulae[:dim] = proc{|r, *rest| seen_rest = rest; 1}
|
|
81
|
+
row[:dim]
|
|
82
|
+
_(seen_rest).must_equal []
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "raises ArgumentError naming the formula when an arity-2 formula has no Namo context" do
|
|
86
|
+
formulae[:sma] = ->(r, n){n.count}
|
|
87
|
+
error = _(proc{row[:sma]}).must_raise ArgumentError
|
|
88
|
+
_(error.message).must_match(/sma/)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "resolves an arity-1 formula on a Row with no Namo context" do
|
|
92
|
+
formulae[:revenue] = ->(r){r[:price] * r[:quantity]}
|
|
93
|
+
_(row[:revenue]).must_equal 1000.0
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
describe "#[] parameterised formulae" do
|
|
98
|
+
let(:namo) do
|
|
99
|
+
Namo.new([row_data])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
let(:contextual_row) do
|
|
103
|
+
Namo::Row.new(row_data, formulae, namo)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "calls an arity-3 formula with the Row, the yielding Namo, and one argument" do
|
|
107
|
+
seen = nil
|
|
108
|
+
formulae[:scaled] = ->(r, n, factor){seen = [r, n, factor]; r[:price] * factor}
|
|
109
|
+
_(contextual_row[:scaled, 3]).must_equal 30.0
|
|
110
|
+
_(seen[0]).must_be_same_as contextual_row
|
|
111
|
+
_(seen[1].equal?(namo)).must_equal true
|
|
112
|
+
_(seen[2]).must_equal 3
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "calls an arity-4 formula with two arguments" do
|
|
116
|
+
formulae[:metric] = ->(r, n, field, factor){r[field] * factor}
|
|
117
|
+
_(contextual_row[:metric, :quantity, 2]).must_equal 200
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "forwards a trailing splat's arguments past a required one (arity -4)" do
|
|
121
|
+
formulae[:dim] = proc{|r, n, field, *rest| [field, rest]}
|
|
122
|
+
_(contextual_row[:dim, :price]).must_equal [:price, []]
|
|
123
|
+
_(contextual_row[:dim, :price, 1, 2]).must_equal [:price, [1, 2]]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "treats a splat directly after namo as collection-scoped taking any number of arguments (arity -3)" do
|
|
127
|
+
formulae[:dim] = proc{|r, n, *rest| rest}
|
|
128
|
+
_(contextual_row[:dim]).must_equal []
|
|
129
|
+
_(contextual_row[:dim, 1, 2, 3]).must_equal [1, 2, 3]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "keeps a one-required-parameter proc row-scoped regardless of trailing optionals" do
|
|
133
|
+
seen = :unset
|
|
134
|
+
formulae[:dim] = ->(r, n = :fallback){seen = n; 1}
|
|
135
|
+
contextual_row[:dim]
|
|
136
|
+
_(seen).must_equal :fallback
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "lets a row-scoped formula call a parameterised formula with arguments" do
|
|
140
|
+
formulae[:metric] = ->(r, n, field, factor){r[field] * factor}
|
|
141
|
+
formulae[:double_quantity] = ->(r){r[:metric, :quantity, 2]}
|
|
142
|
+
_(contextual_row[:double_quantity]).must_equal 200
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "raises ArgumentError naming the formula when a parameterised formula has no Namo context" do
|
|
146
|
+
formulae[:metric] = ->(r, n, field){r[field]}
|
|
147
|
+
error = _(proc{row[:metric, :price]}).must_raise ArgumentError
|
|
148
|
+
_(error.message).must_match(/metric/)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
describe "#[] argument-count enforcement" do
|
|
153
|
+
let(:namo) do
|
|
154
|
+
Namo.new([row_data])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
let(:contextual_row) do
|
|
158
|
+
Namo::Row.new(row_data, formulae, namo)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "raises when a parameterised formula is given too few arguments" do
|
|
162
|
+
formulae[:metric] = ->(r, n, field, period){r[field] * period}
|
|
163
|
+
error = _(proc{contextual_row[:metric, :price]}).must_raise ArgumentError
|
|
164
|
+
_(error.message).must_equal "wrong number of arguments for :metric (given 1, expected 2)"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it "raises when a fixed-arity parameterised formula is given too many arguments" do
|
|
168
|
+
formulae[:metric] = ->(r, n, field){r[field]}
|
|
169
|
+
error = _(proc{contextual_row[:metric, :price, 20]}).must_raise ArgumentError
|
|
170
|
+
_(error.message).must_equal "wrong number of arguments for :metric (given 2, expected 1)"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "raises when a splatted parameterised formula is given fewer than its required arguments" do
|
|
174
|
+
formulae[:metric] = proc{|r, n, field, *rest| r[field]}
|
|
175
|
+
error = _(proc{contextual_row[:metric]}).must_raise ArgumentError
|
|
176
|
+
_(error.message).must_equal "wrong number of arguments for :metric (given 0, expected 1+)"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it "raises when arguments are given for a data dimension" do
|
|
180
|
+
error = _(proc{row[:price, 20]}).must_raise ArgumentError
|
|
181
|
+
_(error.message).must_equal "wrong number of arguments for :price (given 1, expected 0)"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it "raises when arguments are given for a row-scoped formula" do
|
|
185
|
+
formulae[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
186
|
+
error = _(proc{row[:revenue, 20]}).must_raise ArgumentError
|
|
187
|
+
_(error.message).must_equal "wrong number of arguments for :revenue (given 1, expected 0)"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "raises when arguments are given for a two-arity formula" do
|
|
191
|
+
formulae[:row_count] = ->(r, n){n.count}
|
|
192
|
+
error = _(proc{contextual_row[:row_count, 1]}).must_raise ArgumentError
|
|
193
|
+
_(error.message).must_equal "wrong number of arguments for :row_count (given 1, expected 0)"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it "raises when arguments are given for a missing dimension" do
|
|
197
|
+
error = _(proc{row[:missing, 1]}).must_raise ArgumentError
|
|
198
|
+
_(error.message).must_equal "wrong number of arguments for :missing (given 1, expected 0)"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
describe "#match?" do
|
|
203
|
+
it "matches a single value" do
|
|
204
|
+
_(row.match?(product: 'Widget')).must_equal true
|
|
205
|
+
_(row.match?(product: 'Gadget')).must_equal false
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "matches an array of values" do
|
|
209
|
+
_(row.match?(product: ['Widget', 'Gadget'])).must_equal true
|
|
210
|
+
_(row.match?(product: ['Gadget'])).must_equal false
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
it "matches a range" do
|
|
214
|
+
_(row.match?(price: 5.0..15.0)).must_equal true
|
|
215
|
+
_(row.match?(price: 20.0..30.0)).must_equal false
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it "matches multiple dimensions" do
|
|
219
|
+
_(row.match?(product: 'Widget', quarter: 'Q1')).must_equal true
|
|
220
|
+
_(row.match?(product: 'Widget', quarter: 'Q2')).must_equal false
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it "resolves a two-arity derived dimension when the Row carries a Namo" do
|
|
224
|
+
namo = Namo.new([row_data])
|
|
225
|
+
formulae[:row_count] = ->(r, n){n.count}
|
|
226
|
+
row = Namo::Row.new(row_data, formulae, namo)
|
|
227
|
+
_(row.match?(row_count: 1)).must_equal true
|
|
228
|
+
_(row.match?(row_count: 2)).must_equal false
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
describe "Proc predicates" do
|
|
232
|
+
it "matches when the proc returns true" do
|
|
233
|
+
_(row.match?(price: ->(v){v < 15.0})).must_equal true
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it "doesn't match when the proc returns false" do
|
|
237
|
+
_(row.match?(price: ->(v){v > 100.0})).must_equal false
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it "doesn't match when the proc returns nil" do
|
|
241
|
+
_(row.match?(price: ->(v){nil})).must_equal false
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it "matches when the proc returns a truthy non-boolean" do
|
|
245
|
+
_(row.match?(price: ->(v){"truthy"})).must_equal true
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it "passes nil to the proc when the dimension is missing" do
|
|
249
|
+
seen = nil
|
|
250
|
+
row.match?(missing: ->(v){seen = v; true})
|
|
251
|
+
_(seen).must_be_nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
it "lets the proc decide what to do with a nil value" do
|
|
255
|
+
_(row.match?(missing: ->(v){v.nil?})).must_equal true
|
|
256
|
+
_(row.match?(missing: ->(v){!v.nil?})).must_equal false
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
it "composes with an exact value on another dimension" do
|
|
260
|
+
_(row.match?(price: ->(v){v < 15.0}, product: 'Widget')).must_equal true
|
|
261
|
+
_(row.match?(price: ->(v){v < 15.0}, product: 'Gadget')).must_equal false
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
it "composes with an array on another dimension" do
|
|
265
|
+
_(row.match?(price: ->(v){v < 15.0}, product: ['Widget', 'Gadget'])).must_equal true
|
|
266
|
+
_(row.match?(price: ->(v){v < 15.0}, product: ['Gadget'])).must_equal false
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it "composes with a range on another dimension" do
|
|
270
|
+
_(row.match?(price: ->(v){v < 15.0}, quantity: 50..150)).must_equal true
|
|
271
|
+
_(row.match?(price: ->(v){v < 15.0}, quantity: 200..300)).must_equal false
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
it "composes with a regex on another dimension" do
|
|
275
|
+
_(row.match?(price: ->(v){v < 15.0}, product: /^W/)).must_equal true
|
|
276
|
+
_(row.match?(price: ->(v){v < 15.0}, product: /^G/)).must_equal false
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it "composes multiple proc predicates across dimensions" do
|
|
280
|
+
_(row.match?(
|
|
281
|
+
price: ->(v){v < 15.0},
|
|
282
|
+
quantity: ->(v){v >= 100}
|
|
283
|
+
)).must_equal true
|
|
284
|
+
_(row.match?(
|
|
285
|
+
price: ->(v){v < 15.0},
|
|
286
|
+
quantity: ->(v){v >= 200}
|
|
287
|
+
)).must_equal false
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
it "carries through to a formula-defined dimension" do
|
|
291
|
+
formulae[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
292
|
+
_(row.match?(revenue: ->(v){v == 1000.0})).must_equal true
|
|
293
|
+
_(row.match?(revenue: ->(v){v > 5000.0})).must_equal false
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
describe "Regexp predicates" do
|
|
298
|
+
it "matches against a String value" do
|
|
299
|
+
_(row.match?(product: /Widget/)).must_equal true
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
it "doesn't match when the regex doesn't apply" do
|
|
303
|
+
_(row.match?(product: /Gadget/)).must_equal false
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
it "supports case-insensitive matching" do
|
|
307
|
+
_(row.match?(product: /widget/i)).must_equal true
|
|
308
|
+
_(row.match?(product: /widget/)).must_equal false
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
it "supports anchored patterns" do
|
|
312
|
+
_(row.match?(product: /^Wid/)).must_equal true
|
|
313
|
+
_(row.match?(product: /^Gad/)).must_equal false
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
it "coerces Integer values via to_s" do
|
|
317
|
+
_(row.match?(quantity: /100/)).must_equal true
|
|
318
|
+
_(row.match?(quantity: /^1/)).must_equal true
|
|
319
|
+
_(row.match?(quantity: /^9/)).must_equal false
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
it "coerces Float values via to_s" do
|
|
323
|
+
_(row.match?(price: /^10\./)).must_equal true
|
|
324
|
+
_(row.match?(price: /\.0$/)).must_equal true
|
|
325
|
+
_(row.match?(price: /^99/)).must_equal false
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
it "coerces Date values via to_s" do
|
|
329
|
+
row_data[:date] = Date.new(2026, 5, 21)
|
|
330
|
+
_(row.match?(date: /^2026/)).must_equal true
|
|
331
|
+
_(row.match?(date: /-05-/)).must_equal true
|
|
332
|
+
_(row.match?(date: /^2025/)).must_equal false
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
it "coerces Symbol values via to_s" do
|
|
336
|
+
row_data[:tag] = :priority
|
|
337
|
+
_(row.match?(tag: /priority/)).must_equal true
|
|
338
|
+
_(row.match?(tag: /^pri/)).must_equal true
|
|
339
|
+
_(row.match?(tag: /xyz/)).must_equal false
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
it "coerces nil to an empty string" do
|
|
343
|
+
_(row.match?(missing: //)).must_equal true
|
|
344
|
+
_(row.match?(missing: /./)).must_equal false
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
it "composes with an exact value on another dimension" do
|
|
348
|
+
_(row.match?(product: /^W/, quarter: 'Q1')).must_equal true
|
|
349
|
+
_(row.match?(product: /^W/, quarter: 'Q2')).must_equal false
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
it "composes with an array on another dimension" do
|
|
353
|
+
_(row.match?(product: /^W/, quarter: ['Q1', 'Q2'])).must_equal true
|
|
354
|
+
_(row.match?(product: /^W/, quarter: ['Q3'])).must_equal false
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
it "composes with a range on another dimension" do
|
|
358
|
+
_(row.match?(product: /^W/, price: 5.0..15.0)).must_equal true
|
|
359
|
+
_(row.match?(product: /^W/, price: 20.0..30.0)).must_equal false
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
it "composes with a proc on another dimension" do
|
|
363
|
+
_(row.match?(product: /^W/, quantity: ->(v){v >= 100})).must_equal true
|
|
364
|
+
_(row.match?(product: /^W/, quantity: ->(v){v >= 200})).must_equal false
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
it "composes multiple regex predicates across dimensions" do
|
|
368
|
+
_(row.match?(product: /^W/, quarter: /^Q/)).must_equal true
|
|
369
|
+
_(row.match?(product: /^W/, quarter: /^X/)).must_equal false
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
it "carries through to a formula-defined dimension" do
|
|
373
|
+
formulae[:label] = proc{|r| "#{r[:product]}-#{r[:quarter]}"}
|
|
374
|
+
_(row.match?(label: /Widget-Q1/)).must_equal true
|
|
375
|
+
_(row.match?(label: /Gadget/)).must_equal false
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
describe "#to_h" do
|
|
381
|
+
it "returns the underlying row hash" do
|
|
382
|
+
_(row.to_h).must_equal row_data
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
describe "#==" do
|
|
387
|
+
it "is true for two Rows with equal @row" do
|
|
388
|
+
a = Namo::Row.new({product: 'Widget', price: 10.0}, {})
|
|
389
|
+
b = Namo::Row.new({product: 'Widget', price: 10.0}, {})
|
|
390
|
+
_(a == b).must_equal true
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
it "is false for two Rows with different @row" do
|
|
394
|
+
a = Namo::Row.new({product: 'Widget', price: 10.0}, {})
|
|
395
|
+
b = Namo::Row.new({product: 'Gadget', price: 10.0}, {})
|
|
396
|
+
_(a == b).must_equal false
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
it "is false for a non-Row operand" do
|
|
400
|
+
a = Namo::Row.new({product: 'Widget'}, {})
|
|
401
|
+
_(a == {product: 'Widget'}).must_equal false
|
|
402
|
+
_(a == 'Widget').must_equal false
|
|
403
|
+
_(a == nil).must_equal false
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
it "ignores formulae" do
|
|
407
|
+
a = Namo::Row.new({price: 10.0, quantity: 100}, {})
|
|
408
|
+
b = Namo::Row.new({price: 10.0, quantity: 100}, {revenue: proc{|r| r[:price] * r[:quantity]}})
|
|
409
|
+
_(a == b).must_equal true
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
describe "#eql?" do
|
|
414
|
+
it "is true for two Rows with eql? @row" do
|
|
415
|
+
a = Namo::Row.new({product: 'Widget', price: 10.0}, {})
|
|
416
|
+
b = Namo::Row.new({product: 'Widget', price: 10.0}, {})
|
|
417
|
+
_(a.eql?(b)).must_equal true
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
it "is false for a non-Row operand" do
|
|
421
|
+
a = Namo::Row.new({product: 'Widget'}, {})
|
|
422
|
+
_(a.eql?({product: 'Widget'})).must_equal false
|
|
423
|
+
_(a.eql?(nil)).must_equal false
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
it "distinguishes numeric types the way Hash#eql? does" do
|
|
427
|
+
a = Namo::Row.new({n: 1}, {})
|
|
428
|
+
b = Namo::Row.new({n: 1.0}, {})
|
|
429
|
+
_(a == b).must_equal true
|
|
430
|
+
_(a.eql?(b)).must_equal false
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
it "ignores formulae" do
|
|
434
|
+
a = Namo::Row.new({price: 10.0, quantity: 100}, {})
|
|
435
|
+
b = Namo::Row.new({price: 10.0, quantity: 100}, {revenue: proc{|r| r[:price] * r[:quantity]}})
|
|
436
|
+
_(a.eql?(b)).must_equal true
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
describe "#hash" do
|
|
441
|
+
it "is equal for two Rows that are eql?" do
|
|
442
|
+
a = Namo::Row.new({product: 'Widget', price: 10.0}, {})
|
|
443
|
+
b = Namo::Row.new({product: 'Widget', price: 10.0}, {})
|
|
444
|
+
_(a.hash).must_equal b.hash
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
it "lets Rows work as Hash keys" do
|
|
448
|
+
a = Namo::Row.new({product: 'Widget'}, {})
|
|
449
|
+
b = Namo::Row.new({product: 'Gadget'}, {})
|
|
450
|
+
lookup = Namo::Row.new({product: 'Widget'}, {})
|
|
451
|
+
h = {a => :x, b => :y}
|
|
452
|
+
_(h[lookup]).must_equal :x
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
it "lets Array#uniq dedupe equal Rows" do
|
|
456
|
+
a = Namo::Row.new({product: 'Widget'}, {})
|
|
457
|
+
b = Namo::Row.new({product: 'Gadget'}, {})
|
|
458
|
+
duplicate_of_a = Namo::Row.new({product: 'Widget'}, {})
|
|
459
|
+
_([a, b, duplicate_of_a].uniq.length).must_equal 2
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
describe "#inspect" do
|
|
464
|
+
it "renders the row data" do
|
|
465
|
+
_(Namo::Row.new({a: 1}, Namo::Formulae.new).inspect).must_equal "#<Namo::Row {a: 1}>"
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
it "shows a derived dimension with its value, in among the stored ones" do
|
|
469
|
+
formulae = Namo::Formulae.new
|
|
470
|
+
formulae[:b] = proc{|row| row[:a] + 1}
|
|
471
|
+
_(Namo::Row.new({a: 1}, formulae).inspect).must_equal "#<Namo::Row {a: 1, b: 2} derived: [:b]>"
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
it "names a derived dimension whose formula raises, without a value" do
|
|
475
|
+
formulae = Namo::Formulae.new
|
|
476
|
+
formulae[:b] = proc{|row| raise 'no value to show'}
|
|
477
|
+
_(Namo::Row.new({a: 1}, formulae).inspect).must_equal "#<Namo::Row {a: 1} derived: [:b]>"
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
it "does not render the Namo the row came from" do
|
|
481
|
+
namo = Namo.new((1..1000).map{|i| {a: i}})
|
|
482
|
+
_(namo.first.inspect).wont_match(/\{a: 2\}/)
|
|
483
|
+
_(namo.first.inspect.length).must_be :<, 100
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
end
|
data/test/Symbol_test.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require 'minitest/autorun'
|
|
2
|
+
require 'minitest-spec-context'
|
|
3
|
+
|
|
4
|
+
require_relative '../lib/namo'
|
|
5
|
+
|
|
6
|
+
describe Symbol do
|
|
7
|
+
describe "#-@" do
|
|
8
|
+
it "returns a NegatedDimension" do
|
|
9
|
+
_(-:price).must_be_kind_of Namo::NegatedDimension
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "preserves the symbol name" do
|
|
13
|
+
_((-:price).name).must_equal :price
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# test/console_test.rb
|
|
2
|
+
|
|
3
|
+
require 'minitest/autorun'
|
|
4
|
+
require 'minitest-spec-context'
|
|
5
|
+
|
|
6
|
+
# bin/console is the conventional gem prompt: irb with the library loaded and
|
|
7
|
+
# nothing else. It held the demo's fixtures until 20260826, so that a question
|
|
8
|
+
# asked here was asked of the data the demo had shown — a guarantee the demo's
|
|
9
|
+
# own `i` now keeps better, opening irb on the binding the run is using rather
|
|
10
|
+
# than on a second process holding equal values.
|
|
11
|
+
#
|
|
12
|
+
# What is left to assert is that it starts, that Namo is there, and that it needs
|
|
13
|
+
# nothing off the load path.
|
|
14
|
+
|
|
15
|
+
describe 'bin/console' do
|
|
16
|
+
def console(*expressions)
|
|
17
|
+
root = File.expand_path('..', __dir__)
|
|
18
|
+
IO.popen([File.join(root, 'bin', 'console'), '--prompt', 'simple'],
|
|
19
|
+
'r+', chdir: root, err: [:child, :out]) do |io|
|
|
20
|
+
io.puts(expressions, 'exit')
|
|
21
|
+
io.close_write
|
|
22
|
+
io.read
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "starts with Namo loaded" do
|
|
27
|
+
_(console('Namo.new([{a: 1}]).dimensions.inspect')).must_match(/\[:a\]/)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "starts without echoing its own source" do
|
|
31
|
+
_(console('1')).wont_match(/IRB\.start/)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "needs nothing but the gem's own dependencies" do
|
|
35
|
+
root = File.expand_path('..', __dir__)
|
|
36
|
+
output = IO.popen({'RUBYLIB' => nil}, [File.join(root, 'bin', 'console'), '--prompt', 'simple'],
|
|
37
|
+
'r+', chdir: root, err: [:child, :out]) do |io|
|
|
38
|
+
io.puts('exit')
|
|
39
|
+
io.close_write
|
|
40
|
+
io.read
|
|
41
|
+
end
|
|
42
|
+
_(output).wont_match(/LoadError/)
|
|
43
|
+
end
|
|
44
|
+
end
|
data/test/demo_test.rb
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# test/demo_test.rb
|
|
2
|
+
|
|
3
|
+
require 'minitest/autorun'
|
|
4
|
+
require 'minitest-spec-context'
|
|
5
|
+
|
|
6
|
+
# script/demo is 300-odd lines exercising selection, projection, formulae,
|
|
7
|
+
# group_by, summary, the operators and inspect, and nothing else runs it. These
|
|
8
|
+
# assert only that each section completes, which is the difference between a
|
|
9
|
+
# demonstration which rots quietly and one which fails the suite when the library
|
|
10
|
+
# moves under it.
|
|
11
|
+
#
|
|
12
|
+
# A process apiece, since the script is a program rather than a library and its
|
|
13
|
+
# sections share a binding within a run.
|
|
14
|
+
|
|
15
|
+
describe 'script/demo' do
|
|
16
|
+
def demo(*sections)
|
|
17
|
+
root = File.expand_path('..', __dir__)
|
|
18
|
+
IO.popen([File.join(root, 'script', 'demo'), *sections],
|
|
19
|
+
chdir: root, err: [:child, :out]){|io| io.read}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def sections
|
|
23
|
+
@sections ||= demo('--help').scan(/^ [* ] ([a-z_]+)$/).flatten
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def cut
|
|
27
|
+
@cut ||= demo('--help').scan(/^ \* ([a-z_]+)$/).flatten
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "lists its sections" do
|
|
31
|
+
_(sections).wont_be_empty
|
|
32
|
+
_(sections).must_include 'ingestion'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "runs every section without raising" do
|
|
36
|
+
failed = sections.reject do |section|
|
|
37
|
+
demo(section)
|
|
38
|
+
$?.success?
|
|
39
|
+
end
|
|
40
|
+
_(failed).must_be_empty
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "has a talk cut, and it is a subset of the sections" do
|
|
44
|
+
_(cut).wont_be_empty
|
|
45
|
+
_(cut - sections).must_be_empty
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "runs the talk cut" do
|
|
49
|
+
demo('talk')
|
|
50
|
+
_($?.success?).must_equal true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The padding is what holds a section title in one place on the screen, and it
|
|
54
|
+
# can only pad down to the constants. A section grown past them would silently
|
|
55
|
+
# start pushing the next one about. Rows rather than lines, since a line wider
|
|
56
|
+
# than the window costs more than one of them.
|
|
57
|
+
#
|
|
58
|
+
# The cut rather than every section: those are the ones which have to fit on the
|
|
59
|
+
# day, and the rest are reference, free to run long and be scrolled.
|
|
60
|
+
it "has no section in the talk cut taller than the slide height" do
|
|
61
|
+
source = File.read(File.join(File.expand_path('..', __dir__), 'script', 'demo'))
|
|
62
|
+
height, width = %w[SLIDE_HEIGHT SLIDE_WIDTH].map{|name| source[/^#{name} = (\d+)$/, 1].to_i}
|
|
63
|
+
_([height, width].min).must_be :>, 0
|
|
64
|
+
overlong = cut.select do |section|
|
|
65
|
+
demo(section).lines.sum{|line| [(line.chomp.size / width.to_f).ceil, 1].max} > height
|
|
66
|
+
end
|
|
67
|
+
_(overlong).must_be_empty
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "runs the whole script" do
|
|
71
|
+
demo
|
|
72
|
+
_($?.success?).must_equal true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "refuses a section it does not have" do
|
|
76
|
+
demo('nonexistent')
|
|
77
|
+
_($?.success?).must_equal false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "needs nothing but the gem's own dependencies" do
|
|
81
|
+
root = File.expand_path('..', __dir__)
|
|
82
|
+
output = IO.popen({'RUBYLIB' => nil}, [File.join(root, 'script', 'demo'), 'ingestion'],
|
|
83
|
+
chdir: root, err: [:child, :out]){|io| io.read}
|
|
84
|
+
_(output).wont_match(/LoadError/)
|
|
85
|
+
end
|
|
86
|
+
end
|