dsv 0.12.4

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.
data/test/dsv_test.rb ADDED
@@ -0,0 +1,702 @@
1
+ # dsv_test.rb
2
+
3
+ # 20260908, 09
4
+
5
+ # A specification of DSV's interface: what it does today and should go on
6
+ # doing, and what it should do and does not yet. The second kind is skipped, the
7
+ # skip message naming the finding in the Handoff 0 closing note
8
+ # (dsv/closing-note.md) or the observation that it answers, so that the skip
9
+ # count is the fault list and each fix is one skip removed.
10
+ #
11
+ # Four findings have no example, since each waits on a decision that the test
12
+ # would prejudge: 5 (duplicate header names, Decision point 6), 9 (header_row as
13
+ # a class method, Decision point 9), 12 (selected_columns:, Decision point 8) and
14
+ # 17 (writing with no columns defined, Decision point 7). An example whose skip
15
+ # message names a decision point states the option the closing note leans to
16
+ # and is provisional until the decision is recorded.
17
+ #
18
+ # The RFC 4180 cases are taken from stdlib CSV's parsing tests, rewritten here.
19
+ # One convention differs from CSV throughout: an empty field reads as "" rather
20
+ # than nil, as it does in DSV today. Whether that stays is part of the
21
+ # open question of surface compatibility with CSV.
22
+
23
+ require_relative './helper'
24
+
25
+ DATA = "a,b,c\n1,2,3\n4,5,6\n"
26
+ KEYED = [{'a' => '1', 'b' => '2', 'c' => '3'}, {'a' => '4', 'b' => '5', 'c' => '6'}]
27
+ POSITIONAL = [{0 => '1', 1 => '2', 2 => '3'}, {0 => '4', 1 => '5', 2 => '6'}]
28
+ ARRAYS = [['1', '2', '3'], ['4', '5', '6']]
29
+
30
+ describe DSV do
31
+ # A writer over a StringIO, returning what was written.
32
+ def written(*arguments)
33
+ io = StringIO.new
34
+ csv = DSV.new(io, *arguments)
35
+ yield csv
36
+ io.string
37
+ end
38
+
39
+ # The rows a block-taking class method yields.
40
+ def yielded(method, *arguments)
41
+ rows = []
42
+ DSV.send(method, *arguments){|row| rows << row}
43
+ rows
44
+ end
45
+
46
+ # A temporary file holding DATA, given to the block by path.
47
+ def with_file(contents = DATA)
48
+ Dir.mktmpdir('DSV') do |directory|
49
+ path = File.join(directory, 'data.csv')
50
+ File.write(path, contents)
51
+ yield path, directory
52
+ end
53
+ end
54
+
55
+ # How many times a class's new is called while the block runs.
56
+ def constructions(klass)
57
+ count = 0
58
+ original = klass.method(:new)
59
+ klass.stub(:new, ->(*arguments, &block){count += 1; original.call(*arguments, &block)}){yield}
60
+ count
61
+ end
62
+
63
+ describe ".read" do
64
+ it "reads rows keyed by position when there is no header row" do
65
+ _(DSV.read("1,2,3\n4,5,6\n")).must_equal POSITIONAL
66
+ end
67
+
68
+ it "reads rows keyed by column name when headers: is true" do
69
+ _(DSV.read(DATA, headers: true)).must_equal KEYED
70
+ end
71
+
72
+ it "accepts header_row: and header: as spellings of headers:" do
73
+ _(DSV.read(DATA, header_row: true)).must_equal KEYED
74
+ _(DSV.read(DATA, header: true)).must_equal KEYED
75
+ end
76
+
77
+ it "keys by string, so header names are strings" do
78
+ _(DSV.read(DATA, headers: true).first.keys).must_equal ['a', 'b', 'c']
79
+ end
80
+
81
+ it "reads rows as arrays when as_array: is true" do
82
+ _(DSV.read(DATA, headers: true, as_array: true)).must_equal ARRAYS
83
+ _(DSV.read("1,2,3\n4,5,6\n", as_array: true)).must_equal ARRAYS
84
+ end
85
+
86
+ it "names the columns of a headerless source from columns: given as an array" do
87
+ _(DSV.read("1,2,3\n4,5,6\n", columns: [:x, :y, :z])).must_equal [{'x' => '1', 'y' => '2', 'z' => '3'}, {'x' => '4', 'y' => '5', 'z' => '6'}]
88
+ end
89
+
90
+ it "maps names to positions from columns: given as a hash" do
91
+ rows = DSV.read("1,2,3\n4,5,6\n", columns: {x: 0, z: 2})
92
+ _(rows.map{|row| row.values_at('x', 'z')}).must_equal [['1', '2'], ['4', '5']]
93
+ end
94
+
95
+ it "reads an empty source as no rows" do
96
+ _(DSV.read('', headers: true)).must_equal []
97
+ end
98
+
99
+ it "yields each row instead of returning them when given a block" do
100
+ _(yielded(:read, DATA, headers: true)).must_equal KEYED
101
+ end
102
+
103
+ it "is aliased read_csv" do
104
+ _(DSV.read_csv(DATA, headers: true)).must_equal KEYED
105
+ end
106
+
107
+ it "selects columns given as arguments, as #read does" do
108
+ _(DSV.read(DATA, 'a', headers: true)).must_equal [{'a' => '1'}, {'a' => '4'}]
109
+ _(DSV.read(DATA, ['a', 'c'], headers: true)).must_equal [{'a' => '1', 'c' => '3'}, {'a' => '4', 'c' => '6'}]
110
+ _(yielded(:each, DATA, 'a', headers: true)).must_equal [{'a' => '1'}, {'a' => '4'}]
111
+ _(yielded(:parse, DATA, 'b', headers: true)).must_equal [{'b' => '2'}, {'b' => '5'}]
112
+ end
113
+ end
114
+
115
+ describe ".parse" do
116
+ it "reads like .read without a block" do
117
+ _(DSV.parse(DATA, headers: true)).must_equal KEYED
118
+ end
119
+
120
+ it "yields each row with a block" do
121
+ _(yielded(:parse, DATA, headers: true)).must_equal KEYED
122
+ end
123
+
124
+ it "is aliased parse_csv" do
125
+ _(DSV.parse_csv(DATA, headers: true)).must_equal KEYED
126
+ end
127
+ end
128
+
129
+ describe ".each" do
130
+ it "yields each row" do
131
+ _(yielded(:each, DATA, headers: true)).must_equal KEYED
132
+ end
133
+
134
+ it "returns the rows" do
135
+ _(DSV.each(DATA, headers: true){|row| }).must_equal KEYED
136
+ end
137
+
138
+ it "is aliased foreach" do
139
+ _(yielded(:foreach, DATA, headers: true)).must_equal KEYED
140
+ end
141
+ end
142
+
143
+ describe ".collect, .select, .reject and .detect" do
144
+ it "collects the block value for each row, also as map" do
145
+ _(DSV.collect(DATA, headers: true){|row| row['a'].to_i}).must_equal [1, 4]
146
+ _(DSV.map(DATA, headers: true){|row| row['a'].to_i}).must_equal [1, 4]
147
+ end
148
+
149
+ it "selects the rows for which the block is true, also as find_all" do
150
+ _(DSV.select(DATA, headers: true){|row| row['a'] == '4'}).must_equal [KEYED.last]
151
+ _(DSV.find_all(DATA, headers: true){|row| row['a'] == '4'}).must_equal [KEYED.last]
152
+ end
153
+
154
+ it "rejects the rows for which the block is true" do
155
+ _(DSV.reject(DATA, headers: true){|row| row['a'] == '4'}).must_equal [KEYED.first]
156
+ end
157
+
158
+ it "detects the first row for which the block is true, also as find" do
159
+ _(DSV.detect(DATA, headers: true){|row| row['a'] == '4'}).must_equal KEYED.last
160
+ _(DSV.find(DATA, headers: true){|row| row['a'] == '4'}).must_equal KEYED.last
161
+ end
162
+
163
+ it "detects nil when no row matches" do
164
+ _(DSV.detect(DATA, headers: true){|row| false}).must_be_nil
165
+ end
166
+ end
167
+
168
+ describe ".header_row, .first_row, .attributes and .columns" do
169
+ it "header_row returns the header row as names, or nil without one" do
170
+ _(DSV.header_row(DATA, headers: true)).must_equal ['a', 'b', 'c']
171
+ _(DSV.header_row(DATA)).must_be_nil
172
+ end
173
+
174
+ it "first_row returns the first line, separator included" do
175
+ _(DSV.first_row(DATA, headers: true)).must_equal "a,b,c\n"
176
+ end
177
+
178
+ it "attributes returns the column names in order, or nil without a header row" do
179
+ _(DSV.attributes(DATA, headers: true)).must_equal ['a', 'b', 'c']
180
+ _(DSV.attributes(DATA)).must_be_nil
181
+ end
182
+
183
+ it "columns returns the column names mapped to positions, or nil without a header row" do
184
+ _(DSV.columns(DATA, headers: true)).must_equal({'a' => 0, 'b' => 1, 'c' => 2})
185
+ _(DSV.columns(DATA)).must_be_nil
186
+ end
187
+ end
188
+
189
+ describe ".parse_line" do
190
+ it "splits one line into an array of fields" do
191
+ _(DSV.parse_line("1,2,3\n")).must_equal ['1', '2', '3']
192
+ end
193
+
194
+ it "strips quotes from quoted fields" do
195
+ _(DSV.parse_line("\"1\",\"2\"\n")).must_equal ['1', '2']
196
+ end
197
+
198
+ it "takes col_sep: and row_sep:" do
199
+ _(DSV.parse_line("1\t2\t3\n", col_sep: "\t")).must_equal ['1', '2', '3']
200
+ _(DSV.parse_line("1,2,3\r\n", row_sep: "\r\n")).must_equal ['1', '2', '3']
201
+ end
202
+
203
+ it "leaves the caller's string as it was" do
204
+ line = "1,2,3\n"
205
+ DSV.parse_line(line)
206
+ _(line).must_equal "1,2,3\n"
207
+ end
208
+ end
209
+
210
+ describe "parsing per RFC 4180" do
211
+ it "reads unquoted fields, quoted fields, an empty quoted field and a lone field" do
212
+ _(DSV.parse_line("\t")).must_equal ["\t"]
213
+ _(DSV.parse_line('foo')).must_equal ['foo']
214
+ _(DSV.parse_line('""')).must_equal ['']
215
+ _(DSV.parse_line('foo,"",baz')).must_equal ['foo', '', 'baz']
216
+ _(DSV.parse_line('"1997","Ford","E350"')).must_equal ['1997', 'Ford', 'E350']
217
+ end
218
+
219
+ it "keeps spaces around a field, since they are part of it" do
220
+ _(DSV.parse_line('1997, Ford , E350')).must_equal ['1997', ' Ford ', ' E350']
221
+ _(DSV.parse_line('1997,Ford,E350," Super luxurious truck "')).must_equal ['1997', 'Ford', 'E350', ' Super luxurious truck ']
222
+ end
223
+
224
+ it "reads an empty field between or before others as an empty string" do
225
+ _(DSV.parse_line('foo,,baz')).must_equal ['foo', '', 'baz']
226
+ _(DSV.parse_line(',foo,bar')).must_equal ['', 'foo', 'bar']
227
+ end
228
+
229
+ it "reads a separator inside a quoted field" do
230
+ _(DSV.parse_line('"a, b",c')).must_equal ['a, b', 'c']
231
+ end
232
+
233
+ it "reads a line break inside a quoted field of one line" do
234
+ _(DSV.parse_line("foo,\"\r\n\",baz")).must_equal ['foo', "\r\n", 'baz']
235
+ _(DSV.parse_line("foo,\"\n\",baz")).must_equal ['foo', "\n", 'baz']
236
+ end
237
+
238
+ it "reads a doubled quote inside a quoted field as one quote" do
239
+ _(DSV.parse_line('"say ""hi""",2')).must_equal ['say "hi"', '2']
240
+ _(DSV.parse_line('foo,"""bar""",baz')).must_equal ['foo', '"bar"', 'baz']
241
+ _(DSV.parse_line('foo,"""",baz')).must_equal ['foo', '"', 'baz']
242
+ _(DSV.parse_line('foo,"""""",baz')).must_equal ['foo', '""', 'baz']
243
+ end
244
+
245
+ it "reads a trailing empty field as an empty string" do
246
+ _(DSV.parse_line('foo,bar,')).must_equal ['foo', 'bar', '']
247
+ _(DSV.parse_line(',')).must_equal ['', '']
248
+ _(DSV.parse_line(',,')).must_equal ['', '', '']
249
+ end
250
+
251
+ it "reads more than one separator inside a quoted field, and a field that is only a separator" do
252
+ _(DSV.parse_line('"a, b, c",d')).must_equal ['a, b, c', 'd']
253
+ _(DSV.parse_line('","')).must_equal [',']
254
+ _(DSV.parse_line('",",","')).must_equal [',', ',']
255
+ end
256
+
257
+ it "reads a quoted field holding the row separator as one field across lines" do
258
+ _(DSV.read("a,b\n\"x\ny\",2\n", headers: true)).must_equal [{'a' => "x\ny", 'b' => '2'}]
259
+ end
260
+ end
261
+
262
+ describe ".open" do
263
+ it "returns an instance without a block" do
264
+ csv = DSV.open(DATA, headers: true)
265
+ _(csv).must_be_instance_of DSV
266
+ _(csv.read).must_equal KEYED
267
+ end
268
+
269
+ it "yields the instance, closes it and returns it with a block" do
270
+ given = nil
271
+ returned = DSV.open(DATA, headers: true){|csv| given = csv}
272
+ _(returned).must_be_same_as given
273
+ _(given.instance_variable_get(:@source).closed?).must_equal true
274
+ end
275
+
276
+ it "keeps no instance in the class between calls" do
277
+ DSV.open(DATA)
278
+ _(DSV.instance_variable_defined?(:@csv_file)).must_equal false
279
+ end
280
+ end
281
+
282
+ describe ".source_type" do
283
+ it "is DSV::File for the path of an existing file and DSV::String for text" do
284
+ with_file{|path| _(DSV.source_type(path)).must_equal DSV::File}
285
+ _(DSV.source_type(DATA)).must_equal DSV::String
286
+ end
287
+ end
288
+
289
+ describe ".new" do
290
+ it "takes an IO as the source directly" do
291
+ _(DSV.new(StringIO.new(DATA), headers: true).read).must_equal KEYED
292
+ end
293
+
294
+ it "reads only the selected_columns: given at construction when a call selects none, a call's own selection winning" do
295
+ csv = DSV.new(DATA, headers: true, selected_columns: ['a'])
296
+ _(csv.read).must_equal [{'a' => '1'}, {'a' => '4'}]
297
+ _(csv.read('b')).must_equal [{'b' => '2'}, {'b' => '5'}]
298
+ _(DSV.new(DATA, headers: true, selected_columns: 'c').each.to_a).must_equal [{'c' => '3'}, {'c' => '6'}]
299
+ _(DSV.read(DATA, headers: true, selected_columns: [0, 2])).must_equal [{0 => '1', 2 => '3'}, {0 => '4', 2 => '6'}]
300
+ end
301
+
302
+ it "exposes the options as accessors, quote defaulting to nil" do
303
+ csv = DSV.new(DATA, headers: true, mode: 'r', quote: :none, row_separator: "\n", as_array: false)
304
+ _(csv.header_row).must_equal true
305
+ _(csv.header_row?).must_equal true
306
+ _(csv.mode).must_equal 'r'
307
+ _(csv.quote).must_equal :none
308
+ _(csv.row_separator).must_equal "\n"
309
+ _(csv.as_array).must_equal false
310
+ _(DSV.new(DATA).header_row?).must_equal false
311
+ _(DSV.new(DATA).quote).must_be_nil
312
+ end
313
+
314
+ it "reads the header row under mode: given as a symbol or a long name" do
315
+ _(DSV.new(DATA, headers: true, mode: :r).read).must_equal KEYED
316
+ with_file{|path| _(DSV.new(path, headers: true, mode: :read_only).read).must_equal KEYED}
317
+ with_file{|path| _(DSV.new(path, headers: true, mode: 'rw').read).must_equal KEYED}
318
+ end
319
+ end
320
+
321
+ describe "#read" do
322
+ it "returns the rows and keeps them in rows" do
323
+ csv = DSV.new(DATA, headers: true)
324
+ _(csv.rows).must_equal []
325
+ _(csv.read).must_equal KEYED
326
+ _(csv.rows).must_equal KEYED
327
+ end
328
+
329
+ it "selects columns by name, given singly or as an array" do
330
+ _(DSV.new(DATA, headers: true).read('a')).must_equal [{'a' => '1'}, {'a' => '4'}]
331
+ _(DSV.new(DATA, headers: true).read('a', 'c')).must_equal [{'a' => '1', 'c' => '3'}, {'a' => '4', 'c' => '6'}]
332
+ _(DSV.new(DATA, headers: true).read(['a', 'c'])).must_equal [{'a' => '1', 'c' => '3'}, {'a' => '4', 'c' => '6'}]
333
+ end
334
+
335
+ it "selects columns by position, keyed by position even under a header row" do
336
+ _(DSV.new("1,2,3\n4,5,6\n").read(0, 2)).must_equal [{0 => '1', 2 => '3'}, {0 => '4', 2 => '6'}]
337
+ _(DSV.new(DATA, headers: true).read(0, 2)).must_equal [{0 => '1', 2 => '3'}, {0 => '4', 2 => '6'}]
338
+ end
339
+
340
+ it "yields each row with a block, as does #parse" do
341
+ rows = []
342
+ DSV.new(DATA, headers: true).read{|row| rows << row}
343
+ _(rows).must_equal KEYED
344
+ _(DSV.new(DATA, headers: true).parse).must_equal KEYED
345
+ end
346
+
347
+ it "gives a short row only the columns it has" do
348
+ _(DSV.read("a,b,c\n1,2\n", headers: true)).must_equal [{'a' => '1', 'b' => '2'}]
349
+ end
350
+
351
+ it "returns the same rows when read again" do
352
+ csv = DSV.new(DATA, headers: true)
353
+ csv.read
354
+ _(csv.read).must_equal KEYED
355
+ end
356
+ end
357
+
358
+ describe "#each" do
359
+ it "yields each row, reading the source first if it has not been read" do
360
+ rows = []
361
+ DSV.new(DATA, headers: true).each{|row| rows << row}
362
+ _(rows).must_equal KEYED
363
+ end
364
+
365
+ it "yields only the selected columns, before or after a read" do
366
+ rows = []
367
+ DSV.new(DATA, headers: true).each('a'){|row| rows << row}
368
+ _(rows).must_equal [{'a' => '1'}, {'a' => '4'}]
369
+ csv = DSV.new(DATA, headers: true)
370
+ csv.read
371
+ rows = []
372
+ csv.each('a', 'c'){|row| rows << row}
373
+ _(rows).must_equal [{'a' => '1', 'c' => '3'}, {'a' => '4', 'c' => '6'}]
374
+ end
375
+
376
+ it "is aliased each_row and backs Enumerable" do
377
+ _(DSV.include?(Enumerable)).must_equal true
378
+ _(DSV.new(DATA, headers: true).map{|row| row['a']}).must_equal ['1', '4']
379
+ _(DSV.new(DATA, headers: true).count).must_equal 2
380
+ _(DSV.new(DATA, headers: true).first).must_equal KEYED.first
381
+ end
382
+
383
+ it "returns an enumerator without a block" do
384
+ _(DSV.new(DATA, headers: true).each).must_be_kind_of Enumerator
385
+ _(DSV.new(DATA, headers: true).each.to_a).must_equal KEYED
386
+ _(DSV.new(DATA, headers: true).each('a').to_a).must_equal [{'a' => '1'}, {'a' => '4'}]
387
+ end
388
+ end
389
+
390
+ describe "#to_a" do
391
+ it "returns the rows as arrays in column order" do
392
+ _(DSV.new(DATA, headers: true).to_a).must_equal ARRAYS
393
+ end
394
+
395
+ it "returns rows as read when as_array: is true" do
396
+ _(DSV.new(DATA, headers: true, as_array: true).to_a).must_equal ARRAYS
397
+ end
398
+
399
+ it "returns the rows as arrays in position order when there are no columns" do
400
+ _(DSV.new("1,2\n3,4\n").to_a).must_equal [['1', '2'], ['3', '4']]
401
+ end
402
+ end
403
+
404
+ describe "#columns= and #attributes=" do
405
+ it "columns= takes names in order, or a hash of name to position, as strings" do
406
+ csv = DSV.new("1,2\n")
407
+ csv.columns = [:p, :q]
408
+ _(csv.columns).must_equal({'p' => 0, 'q' => 1})
409
+ _(csv.attributes).must_equal ['p', 'q']
410
+ _(csv.read).must_equal [{'p' => '1', 'q' => '2'}]
411
+ csv = DSV.new("1,2\n")
412
+ csv.columns = {p: 0, q: 1}
413
+ _(csv.columns).must_equal({'p' => 0, 'q' => 1})
414
+ end
415
+
416
+ it "attributes= sets the names without setting columns, so rows stay positional" do
417
+ csv = DSV.new("1,2\n")
418
+ csv.attributes = ['p', 'q']
419
+ _(csv.attributes).must_equal ['p', 'q']
420
+ _(csv.columns).must_be_nil
421
+ _(csv.read).must_equal [{0 => '1', 1 => '2'}]
422
+ end
423
+ end
424
+
425
+ describe "column separators on read" do
426
+ it "reads a tab, given as column_separator: or col_sep:" do
427
+ _(DSV.read("a\tb\n1\t2\n", headers: true, column_separator: "\t")).must_equal [{'a' => '1', 'b' => '2'}]
428
+ _(DSV.read("a\tb\n1\t2\n", headers: true, col_sep: "\t")).must_equal [{'a' => '1', 'b' => '2'}]
429
+ end
430
+
431
+ it "reads a pipe and a semicolon" do
432
+ _(DSV.read("a|b\n1|2\n", headers: true, column_separator: '|')).must_equal [{'a' => '1', 'b' => '2'}]
433
+ _(DSV.read("a;b\n1;2\n", headers: true, column_separator: ';')).must_equal [{'a' => '1', 'b' => '2'}]
434
+ end
435
+
436
+ it "reads a multi-character separator" do
437
+ _(DSV.read("a::b\n1::2\n", headers: true, column_separator: '::')).must_equal [{'a' => '1', 'b' => '2'}]
438
+ end
439
+
440
+ it "reads a regular expression separator" do
441
+ _(DSV.read("a, b\n1,2\n", headers: true, column_separator: /,\s*/)).must_equal [{'a' => '1', 'b' => '2'}]
442
+ end
443
+
444
+ it "reads a regular expression separator with a quoted field holding the separator" do
445
+ skip "Observation: reassembling a quoted field concatenates the separator back in, which a Regexp cannot be; the parked scanner parser handles it"
446
+ _(DSV.read("a,b\n\"x, y\",2\n", headers: true, column_separator: /,\s*/)).must_equal [{'a' => 'x, y', 'b' => '2'}]
447
+ end
448
+ end
449
+
450
+ describe "row separators on read" do
451
+ it "reads CRLF, given as row_separator: or row_sep:, and by default" do
452
+ _(DSV.read("a,b\r\n1,2\r\n", headers: true, row_separator: "\r\n")).must_equal [{'a' => '1', 'b' => '2'}]
453
+ _(DSV.read("a,b\r\n1,2\r\n", headers: true, row_sep: "\r\n")).must_equal [{'a' => '1', 'b' => '2'}]
454
+ _(DSV.read("a,b\r\n1,2\r\n", headers: true)).must_equal [{'a' => '1', 'b' => '2'}]
455
+ end
456
+
457
+ it "reads any other row separator" do
458
+ _(DSV.read('a,b|1,2|', headers: true, row_separator: '|')).must_equal [{'a' => '1', 'b' => '2'}]
459
+ end
460
+
461
+ it "reads a blank line as an empty row" do
462
+ _(DSV.read("a,b\n\n1,2\n", headers: true)).must_equal [{}, {'a' => '1', 'b' => '2'}]
463
+ end
464
+ end
465
+
466
+ describe "quoting on read" do
467
+ it "strips quotes from quoted fields and headers by default" do
468
+ _(DSV.read("a,b\n\"x\",\"2\"\n", headers: true)).must_equal [{'a' => 'x', 'b' => '2'}]
469
+ _(DSV.read("\"a\",\"b\"\n1,2\n", headers: true)).must_equal [{'a' => '1', 'b' => '2'}]
470
+ end
471
+
472
+ it "keeps a separator inside a quoted field by default" do
473
+ _(DSV.read("a,b\n\"x, y\",2\n", headers: true)).must_equal [{'a' => 'x, y', 'b' => '2'}]
474
+ end
475
+
476
+ it "keeps the quotes under quote: :none" do
477
+ _(DSV.read("a,b\n\"x\",\"2\"\n", headers: true, quote: :none)).must_equal [{'a' => '"x"', 'b' => '"2"'}]
478
+ end
479
+
480
+ it "strips the quotes under quote: :double, a row being wholly quoted or, as a header row often is, wholly unquoted" do
481
+ _(DSV.read("a,b\n\"x\",\"2\"\n", headers: true, quote: :double)).must_equal [{'a' => 'x', 'b' => '2'}]
482
+ _(DSV.read("\"a\",\"b\"\n\"say \"\"hi\"\"\",\"2\"\n", headers: true, quote: :double)).must_equal [{'a' => 'say "hi"', 'b' => '2'}]
483
+ end
484
+
485
+ it "keeps every position of a repeated header name in columns, the name at each position in attributes" do
486
+ _(DSV.columns("a,a,b\n1,2,3\n", headers: true)).must_equal({'a' => [0, 1], 'b' => 2})
487
+ _(DSV.attributes("a,a,b\n1,2,3\n", headers: true)).must_equal ['a', 'a', 'b']
488
+ _(DSV.columns(",,b\n1,2,3\n", headers: true)).must_equal({'' => [0, 1], 'b' => 2})
489
+ _(DSV.attributes(",,b\n1,2,3\n", headers: true)).must_equal ['', '', 'b']
490
+ end
491
+
492
+ it "reads the values under a repeated header name as an Array, one per position, in order" do
493
+ _(DSV.read("a,a,b\n1,2,3\n4,5,6\n", headers: true)).must_equal [{'a' => ['1', '2'], 'b' => '3'}, {'a' => ['4', '5'], 'b' => '6'}]
494
+ _(DSV.read(",,b\n1,2,3\n", headers: true)).must_equal [{'' => ['1', '2'], 'b' => '3'}]
495
+ _(DSV.new("a,a,b\n1,2,3\n", headers: true).read('a')).must_equal [{'a' => ['1', '2']}]
496
+ end
497
+
498
+ it "spreads a repeated name's Array back over its positions in to_a, as_array and write" do
499
+ _(DSV.new("a,a,b\n1,2,3\n", headers: true).to_a).must_equal [['1', '2', '3']]
500
+ _(DSV.read("a,a,b\n1,2,3\n", headers: true, as_array: true)).must_equal [['1', '2', '3']]
501
+ output = written(headers: true, columns: ['a', 'a', 'b'], quote: :none){|csv| csv.rows = [{'a' => ['1', '2'], 'b' => '3'}]; csv.write}
502
+ _(output).must_equal "a,a,b\n1,2,3\n"
503
+ _(written(columns: ['a', 'a', 'b'], quote: :none){|csv| csv.write_row('a' => 'x', 'b' => '3')}).must_equal "x,x,3\n"
504
+ end
505
+
506
+ it "reads a doubled quote as one quote in a row" do
507
+ _(DSV.read("a,b\n\"say \"\"hi\"\"\",2\n", headers: true)).must_equal [{'a' => 'say "hi"', 'b' => '2'}]
508
+ end
509
+
510
+ it "reads a trailing empty field as an empty string under its column" do
511
+ _(DSV.read("a,b,c\n1,2,\n", headers: true)).must_equal [{'a' => '1', 'b' => '2', 'c' => ''}]
512
+ _(DSV.read("1,2,\n")).must_equal [{0 => '1', 1 => '2', 2 => ''}]
513
+ end
514
+
515
+ it "keeps every separator inside a quoted field in a row" do
516
+ _(DSV.read("\"x, y, z\",2\n")).must_equal [{0 => 'x, y, z', 1 => '2'}]
517
+ end
518
+
519
+ it "keeps a separator inside a quoted field under quote: :double" do
520
+ _(DSV.read("a,b\n\"x, y\",\"2\"\n", headers: true, quote: :double)).must_equal [{'a' => 'x, y', 'b' => '2'}]
521
+ end
522
+ end
523
+
524
+ describe "#write" do
525
+ it "writes each row in column order, double-quoted by default" do
526
+ output = written(columns: [:a, :b]){|csv| csv.rows = [{'a' => 1, 'b' => 2}]; csv.write}
527
+ _(output).must_equal "\"1\",\"2\"\n"
528
+ end
529
+
530
+ it "writes the header row first when headers: is true, and is aliased write_csv" do
531
+ output = written(headers: true, columns: [:a, :b]){|csv| csv.rows = [{'a' => 1, 'b' => 2}]; csv.write_csv}
532
+ _(output).must_equal "\"a\",\"b\"\n\"1\",\"2\"\n"
533
+ end
534
+
535
+ it "writes unquoted under quote: :none, :unquoted or the string none, and single-quoted under :single" do
536
+ _(written(headers: true, columns: [:a, :b], quote: :none){|csv| csv.rows = [{'a' => 1, 'b' => 2}]; csv.write}).must_equal "a,b\n1,2\n"
537
+ _(written(columns: [:a, :b], quote: :unquoted){|csv| csv.rows = [{'a' => 1, 'b' => 2}]; csv.write}).must_equal "1,2\n"
538
+ _(written(columns: [:a, :b], quote: 'none'){|csv| csv.rows = [{'a' => 1, 'b' => 2}]; csv.write}).must_equal "1,2\n"
539
+ _(written(columns: [:a, :b], quote: :single){|csv| csv.rows = [{'a' => 1, 'b' => 2}]; csv.write}).must_equal "'1','2'\n"
540
+ end
541
+
542
+ it "writes only the selected columns" do
543
+ output = written(columns: [:a, :b, :c]){|csv| csv.rows = [{'a' => 1, 'b' => 2, 'c' => 3}]; csv.write('a', 'c')}
544
+ _(output).must_equal "\"1\",\"3\"\n"
545
+ end
546
+
547
+ it "writes what it read back, so a source round-trips" do
548
+ output = written(headers: true, columns: [:a, :b]){|csv| csv.rows = DSV.read("a,b\n1,2\n", headers: true); csv.write}
549
+ _(DSV.read(output, headers: true)).must_equal [{'a' => '1', 'b' => '2'}]
550
+ end
551
+
552
+ it "write_row and write_header write one row and the header row" do
553
+ _(written(columns: [:a, :b]){|csv| csv.write_row('a' => 'x', 'b' => 'y')}).must_equal "\"x\",\"y\"\n"
554
+ _(written(columns: [:a, :b]){|csv| csv.write_header}).must_equal "\"a\",\"b\"\n"
555
+ end
556
+
557
+ it "quotes a value holding a separator, and leaves it bare under quote: :none" do
558
+ _(written(columns: [:a, :b]){|csv| csv.write_row('a' => 'x, y', 'b' => 'z')}).must_equal "\"x, y\",\"z\"\n"
559
+ _(written(columns: [:a, :b], quote: :none){|csv| csv.write_row('a' => 'x, y', 'b' => 'z')}).must_equal "x, y,z\n"
560
+ end
561
+
562
+ it "doubles a quote inside a quoted value, per RFC 4180" do
563
+ _(written(columns: [:a, :b]){|csv| csv.write_row('a' => 'say "hi"', 'b' => 'z')}).must_equal "\"say \"\"hi\"\"\",\"z\"\n"
564
+ end
565
+
566
+ it "writes the selected column names as the header row" do
567
+ output = written(headers: true, columns: [:a, :b, :c], quote: :none){|csv| csv.rows = [{'a' => 1, 'b' => 2, 'c' => 3}]; csv.write('a', 'c')}
568
+ _(output).must_equal "a,c\n1,3\n"
569
+ output = written(headers: true, columns: [:a, :b, :c]){|csv| csv.rows = [{'a' => 1, 'b' => 2, 'c' => 3}]; csv.write('a', 'c')}
570
+ _(output).must_equal "\"a\",\"c\"\n\"1\",\"3\"\n"
571
+ end
572
+
573
+ it "writes a nil value as an empty field, keeping the columns in place" do
574
+ output = written(columns: [:a, :b, :c]){|csv| csv.rows = [{'a' => 1, 'b' => nil, 'c' => 3}]; csv.write}
575
+ _(output).must_equal "\"1\",\"\",\"3\"\n"
576
+ _(written(columns: [:a, :b, :c], quote: :none){|csv| csv.rows = [{'a' => 1, 'b' => nil, 'c' => 3}]; csv.write}).must_equal "1,,3\n"
577
+ end
578
+
579
+ it "writes rows keyed by position in their own order when no columns are defined, with no header" do
580
+ _(written(quote: :none){|csv| csv.rows = [{0 => 1, 1 => 2}]; csv.write}).must_equal "1,2\n"
581
+ _(written(headers: true, quote: :none){|csv| csv.rows = DSV.read("1,2\n3,4\n"); csv.write}).must_equal "1,2\n3,4\n"
582
+ end
583
+
584
+ it "takes the columns, and the header, from the first row's keys when rows are keyed by name and no columns are defined" do
585
+ _(written(headers: true, quote: :none){|csv| csv.rows = [{'a' => 1, 'b' => 2}, {'a' => 3, 'b' => 4}]; csv.write}).must_equal "a,b\n1,2\n3,4\n"
586
+ _(written(quote: :none){|csv| csv.rows = [{'a' => 1, 'b' => 2}]; csv.write}).must_equal "1,2\n"
587
+ end
588
+
589
+ it "writes with the column and row separators given, as it reads with them, and round-trips them" do
590
+ _(written(columns: [:a, :b], column_separator: "\t", quote: :none){|csv| csv.rows = [{'a' => 1, 'b' => 2}]; csv.write}).must_equal "1\t2\n"
591
+ _(written(columns: [:a, :b], row_separator: "\r\n", quote: :none){|csv| csv.rows = [{'a' => 1, 'b' => 2}]; csv.write}).must_equal "1,2\r\n"
592
+ output = written(headers: true, columns: [:a, :b], column_separator: '|', row_separator: "\r\n"){|csv| csv.rows = [{'a' => 'x|y', 'b' => '2'}]; csv.write}
593
+ _(DSV.read(output, headers: true, column_separator: '|', row_separator: "\r\n")).must_equal [{'a' => 'x|y', 'b' => '2'}]
594
+ end
595
+
596
+ it "writes the spacey modes with a space after each separator" do
597
+ _(written(columns: [:a, :b], quote: :spacey_none){|csv| csv.write_row('a' => 1, 'b' => 2)}).must_equal "1, 2\n"
598
+ _(written(columns: [:a, :b], quote: :spacey_double){|csv| csv.write_row('a' => 1, 'b' => 2)}).must_equal "\"1\", \"2\"\n"
599
+ end
600
+ end
601
+
602
+ describe "DSV::String" do
603
+ it "reads a string, and .open behaves as DSV.open" do
604
+ _(DSV::String.new(DATA, headers: true).read).must_equal KEYED
605
+ _(DSV::String.open(DATA, headers: true){|csv| csv.read}).must_be_instance_of DSV::String
606
+ end
607
+
608
+ it ".open constructs one instance" do
609
+ _(constructions(DSV::String){DSV::String.open(DATA){|csv| }}).must_equal 1
610
+ end
611
+ end
612
+
613
+ describe "DSV::File" do
614
+ it "reads a file by path" do
615
+ with_file{|path| _(DSV.read(path, headers: true)).must_equal KEYED}
616
+ with_file{|path| _(DSV::File.new(path, headers: true).read).must_equal KEYED}
617
+ end
618
+
619
+ it "defaults the mode to r, reports it as Ruby's File spells it, and takes permissions:" do
620
+ with_file do |path|
621
+ _(DSV::File.new(path).mode).must_equal 'r'
622
+ _(DSV::File.new(path, mode: :read_only).mode).must_equal 'r'
623
+ _(DSV::File.new(path, mode: 'rw').mode).must_equal 'r+'
624
+ _(DSV.new(path, mode: 'append').mode).must_equal 'a'
625
+ _(DSV::File.new(path, permissions: 0644).permissions).must_equal 0644
626
+ end
627
+ end
628
+
629
+ it "reads the header row under a+ and has no columns under w or w+" do
630
+ with_file{|path| _(DSV.new(path, mode: 'a+', headers: true).columns).must_equal({'a' => 0, 'b' => 1, 'c' => 2})}
631
+ with_file{|path| _(DSV.new(path, mode: 'w', headers: true).columns).must_be_nil}
632
+ with_file{|path| _(DSV.new(path, mode: 'w+', headers: true).columns).must_be_nil}
633
+ end
634
+
635
+ it "writes an existing file under w, and appends under a" do
636
+ with_file do |path|
637
+ DSV.open(path, mode: 'w', headers: true, columns: [:a, :b], quote: :none){|csv| csv.rows = [{'a' => 1, 'b' => 2}]; csv.write}
638
+ _(File.read(path)).must_equal "a,b\n1,2\n"
639
+ end
640
+ with_file do |path|
641
+ DSV.open(path, mode: 'a', columns: [:a, :b, :c], quote: :none){|csv| csv.rows = [{'a' => 7, 'b' => 8, 'c' => 9}]; csv.write}
642
+ _(File.read(path)).must_equal "a,b,c\n1,2,3\n4,5,6\n7,8,9\n"
643
+ end
644
+ end
645
+
646
+ it "rewrites a file in place under r+ when the rows are read, changed and written" do
647
+ with_file do |path|
648
+ DSV.open(path, mode: 'r+', headers: true, quote: :none){|csv| csv.rows = csv.read.map{|row| row.merge('a' => 'X')}; csv.write}
649
+ _(File.read(path)).must_equal "a,b,c\nX,2,3\nX,5,6\n"
650
+ end
651
+ end
652
+
653
+ it "DSV.open yields the instance and closes the file" do
654
+ with_file do |path|
655
+ given = nil
656
+ DSV.open(path, headers: true){|csv| given = csv}
657
+ _(given.instance_variable_get(:@source).closed?).must_equal true
658
+ end
659
+ end
660
+
661
+ it "writes a new file by name under a write mode" do
662
+ skip "Decision point 4: Finding 14, a path that does not exist yet is taken as text; the note leans to a write mode making it a path"
663
+ with_file do |path, directory|
664
+ new_path = File.join(directory, 'new.csv')
665
+ DSV.open(new_path, mode: 'w', headers: true, columns: [:a, :b], quote: :none){|csv| csv.rows = [{'a' => 1, 'b' => 2}]; csv.write}
666
+ _(File.read(new_path)).must_equal "a,b\n1,2\n"
667
+ end
668
+ end
669
+
670
+ it "leaves a file as it was when read under r+ without a write, whichever way it is read" do
671
+ with_file do |path|
672
+ DSV.open(path, mode: 'r+', headers: true){|csv| csv.read}
673
+ _(File.read(path)).must_equal DATA
674
+ end
675
+ with_file do |path|
676
+ _(DSV.new(path, mode: 'r+', headers: true).count).must_equal 2
677
+ _(File.read(path)).must_equal DATA
678
+ end
679
+ end
680
+
681
+ it "replaces the file under r+ on write, reading the rows first if they have not been read" do
682
+ with_file do |path|
683
+ DSV.open(path, mode: 'r+', headers: true, quote: :none){|csv| csv.write}
684
+ _(File.read(path)).must_equal DATA
685
+ end
686
+ with_file do |path|
687
+ DSV.open(path, mode: 'r+', headers: true, quote: :none){|csv| csv.rows = [{'a' => 'X', 'b' => 'Y', 'c' => 'Z'}]; csv.write}
688
+ _(File.read(path)).must_equal "a,b,c\nX,Y,Z\n"
689
+ end
690
+ end
691
+
692
+ it ".open constructs one instance, opening the file once" do
693
+ with_file{|path| _(constructions(DSV::File){DSV::File.open(path){|csv| }}).must_equal 1}
694
+ end
695
+
696
+ it "expands the path it is given" do
697
+ with_file do |path, directory|
698
+ _(DSV::File.new(File.join(directory, '.', 'data.csv')).filename).must_equal path
699
+ end
700
+ end
701
+ end
702
+ end