tapsoob 0.8.5 → 0.8.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,121 @@
1
+ require 'spec_helper'
2
+ require 'tapsoob/data_stream/keyed'
3
+
4
+ Sequel.extension :core_extensions
5
+
6
+ RSpec.describe Tapsoob::DataStream::Keyed do
7
+ let(:db) do
8
+ d = connect_sqlite
9
+ d.extension :schema_dumper
10
+ d.create_table(:keyed_items) { primary_key :id; String :label }
11
+ 10.times { |i| d[:keyed_items].insert(label: "item_#{i}") }
12
+ d
13
+ end
14
+
15
+ after { db.disconnect }
16
+
17
+ subject(:stream) { described_class.new(db, { table_name: :keyed_items, chunksize: 4 }) }
18
+
19
+ # ── primary_key / buffer_limit ───────────────────────────────────────────────
20
+
21
+ describe '#primary_key' do
22
+ it 'returns :id for this table' do
23
+ expect(stream.primary_key).to eq(:id)
24
+ end
25
+ end
26
+
27
+ describe '#buffer_limit' do
28
+ it 'returns filter when buffer is non-empty' do
29
+ stream.state[:filter] = 5
30
+ stream.buffer << { id: 1 }
31
+ expect(stream.buffer_limit).to eq(5)
32
+ end
33
+
34
+ it 'returns last_fetched when it is less than filter and buffer is empty' do
35
+ stream.state[:filter] = 10
36
+ stream.state[:last_fetched] = 3
37
+ stream.buffer.clear
38
+ expect(stream.buffer_limit).to eq(3)
39
+ end
40
+
41
+ it 'returns filter when last_fetched >= filter' do
42
+ stream.state[:filter] = 5
43
+ stream.state[:last_fetched] = 5
44
+ stream.buffer.clear
45
+ expect(stream.buffer_limit).to eq(5)
46
+ end
47
+ end
48
+
49
+ # ── calc_limit ───────────────────────────────────────────────────────────────
50
+
51
+ describe '#calc_limit' do
52
+ it 'returns chunksize * 3 outside Sinatra' do
53
+ expect(stream.calc_limit(100)).to eq(300)
54
+ end
55
+ end
56
+
57
+ # ── load_buffer ──────────────────────────────────────────────────────────────
58
+
59
+ describe '#load_buffer' do
60
+ it 'loads rows into the buffer' do
61
+ stream.load_buffer(4)
62
+ expect(stream.buffer.size).to be >= 4
63
+ end
64
+
65
+ it 'updates state[:filter] to the last PK in the buffer' do
66
+ stream.load_buffer(4)
67
+ expect(stream.state[:filter]).to eq(stream.buffer.last[:id])
68
+ end
69
+
70
+ it 'stops early when the table is exhausted' do
71
+ stream.load_buffer(1000)
72
+ expect(stream.buffer.size).to eq(10)
73
+ end
74
+ end
75
+
76
+ # ── fetch_buffered ───────────────────────────────────────────────────────────
77
+
78
+ describe '#fetch_buffered' do
79
+ it 'returns up to chunksize rows' do
80
+ rows = stream.fetch_buffered(4)
81
+ expect(rows.size).to eq(4)
82
+ end
83
+
84
+ it 'records last_fetched as the PK of the last returned row' do
85
+ rows = stream.fetch_buffered(4)
86
+ expect(stream.state[:last_fetched]).to eq(rows.last[:id])
87
+ end
88
+
89
+ it 'returns nil last_fetched when no rows available' do
90
+ # drain the table
91
+ stream.load_buffer(100)
92
+ stream.buffer.clear
93
+ rows = stream.fetch_buffered(4)
94
+ expect(rows).to be_empty
95
+ expect(stream.state[:last_fetched]).to be_nil
96
+ end
97
+ end
98
+
99
+ # ── increment ────────────────────────────────────────────────────────────────
100
+
101
+ describe '#increment' do
102
+ it 'removes n rows from the front of the buffer' do
103
+ stream.load_buffer(6)
104
+ original_fourth = stream.buffer[3]
105
+ stream.increment(3)
106
+ expect(stream.buffer.first).to eq(original_fourth)
107
+ end
108
+ end
109
+
110
+ # ── verify_stream ────────────────────────────────────────────────────────────
111
+
112
+ describe '#verify_stream' do
113
+ it 'sets filter to the max PK and clears last_fetched' do
114
+ stream.state[:last_fetched] = 3
115
+ stream.verify_stream
116
+ max_id = db[:keyed_items].max(:id)
117
+ expect(stream.state[:filter]).to eq(max_id)
118
+ expect(stream.state[:last_fetched]).to be_nil
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,136 @@
1
+ require 'spec_helper'
2
+ require 'tapsoob/progress_event'
3
+
4
+ RSpec.describe Tapsoob::ProgressEvent do
5
+ before do
6
+ described_class.enabled = true
7
+ # Reset throttle state between examples
8
+ described_class.instance_variable_set(:@last_progress_time, {})
9
+ end
10
+
11
+ after { described_class.enabled = false }
12
+
13
+ # ── enabled? ─────────────────────────────────────────────────────────────────
14
+
15
+ describe '.enabled?' do
16
+ it 'reflects the value set by .enabled=' do
17
+ described_class.enabled = false
18
+ expect(described_class.enabled?).to be false
19
+ described_class.enabled = true
20
+ expect(described_class.enabled?).to be true
21
+ end
22
+ end
23
+
24
+ # ── emit ─────────────────────────────────────────────────────────────────────
25
+
26
+ describe '.emit' do
27
+ it 'writes a PROGRESS: JSON line to STDERR when enabled' do
28
+ err = StringIO.new
29
+ stub_const('STDERR', err)
30
+ described_class.emit('test_event', foo: 'bar')
31
+ output = err.string
32
+ expect(output).to include('PROGRESS:')
33
+ data = JSON.parse(output.sub('PROGRESS: ', ''))
34
+ expect(data['event']).to eq('test_event')
35
+ expect(data['foo']).to eq('bar')
36
+ end
37
+
38
+ it 'does nothing when disabled' do
39
+ described_class.enabled = false
40
+ err = StringIO.new
41
+ stub_const('STDERR', err)
42
+ described_class.emit('ignored')
43
+ expect(err.string).to be_empty
44
+ end
45
+ end
46
+
47
+ # ── throttle helpers ─────────────────────────────────────────────────────────
48
+
49
+ describe '.should_emit_progress?' do
50
+ it 'returns true on the first call for a table' do
51
+ expect(described_class.should_emit_progress?(:my_table)).to be true
52
+ end
53
+
54
+ it 'returns false when called again immediately' do
55
+ described_class.should_emit_progress?(:my_table)
56
+ expect(described_class.should_emit_progress?(:my_table)).to be false
57
+ end
58
+ end
59
+
60
+ describe '.clear_throttle' do
61
+ it 'resets state so the next call returns true' do
62
+ described_class.should_emit_progress?(:my_table)
63
+ described_class.clear_throttle(:my_table)
64
+ expect(described_class.should_emit_progress?(:my_table)).to be true
65
+ end
66
+ end
67
+
68
+ # ── high-level event helpers ──────────────────────────────────────────────────
69
+
70
+ {
71
+ schema_start: [3],
72
+ schema_complete: [3],
73
+ data_start: [3, 100],
74
+ data_complete: [3, 100],
75
+ indexes_start: [3],
76
+ indexes_complete: [3],
77
+ sequences_start: [],
78
+ sequences_complete: [],
79
+ }.each do |method, args|
80
+ describe ".#{method}" do
81
+ it 'does not raise' do
82
+ expect { described_class.send(method, *args) }.not_to raise_error
83
+ end
84
+ end
85
+ end
86
+
87
+ describe '.table_start' do
88
+ it 'resets throttle and emits without error' do
89
+ expect { described_class.table_start(:users, 500, workers: 2) }.not_to raise_error
90
+ end
91
+ end
92
+
93
+ describe '.table_progress' do
94
+ it 'emits when throttle allows' do
95
+ described_class.clear_throttle(:users)
96
+ err = StringIO.new
97
+ stub_const('STDERR', err)
98
+ described_class.table_progress(:users, 50, 200)
99
+ expect(err.string).to include('table_progress')
100
+ end
101
+
102
+ it 'skips emission when throttled' do
103
+ described_class.should_emit_progress?(:users) # consume the token
104
+ err = StringIO.new
105
+ stub_const('STDERR', err)
106
+ described_class.table_progress(:users, 50, 200)
107
+ expect(err.string).to be_empty
108
+ end
109
+
110
+ it 'emits 0% when total is 0' do
111
+ described_class.clear_throttle(:empty_table)
112
+ err = StringIO.new
113
+ stub_const('STDERR', err)
114
+ described_class.table_progress(:empty_table, 0, 0)
115
+ data = JSON.parse(err.string.sub('PROGRESS: ', '').strip)
116
+ expect(data['percentage']).to eq(0)
117
+ end
118
+ end
119
+
120
+ describe '.table_complete' do
121
+ it 'clears throttle and emits without error' do
122
+ expect { described_class.table_complete(:users, 500) }.not_to raise_error
123
+ end
124
+ end
125
+
126
+ describe '.error' do
127
+ it 'emits an error event with the message' do
128
+ err = StringIO.new
129
+ stub_const('STDERR', err)
130
+ described_class.error('something broke', table: 'users')
131
+ data = JSON.parse(err.string.sub('PROGRESS: ', '').strip)
132
+ expect(data['event']).to eq('error')
133
+ expect(data['message']).to eq('something broke')
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,335 @@
1
+ require 'spec_helper'
2
+ require 'tapsoob/progress'
3
+
4
+ RSpec.describe Tapsoob::Progress do
5
+ # All output is redirected to StringIO so nothing hits stdout/terminal.
6
+
7
+ let(:out) { StringIO.new }
8
+
9
+ # ── Bar ──────────────────────────────────────────────────────────────────────
10
+
11
+ describe Tapsoob::Progress::Bar do
12
+ subject(:bar) { described_class.new("Test", 100, out) }
13
+
14
+ describe '#initialize' do
15
+ it 'starts at 0' do
16
+ expect(bar.current).to eq(0)
17
+ end
18
+
19
+ it 'stores total' do
20
+ expect(bar.total).to eq(100)
21
+ end
22
+
23
+ it 'stores title' do
24
+ expect(bar.title).to eq("Test")
25
+ end
26
+ end
27
+
28
+ describe '#inc' do
29
+ it 'advances current by 1 by default' do
30
+ bar.inc
31
+ expect(bar.current).to eq(1)
32
+ end
33
+
34
+ it 'advances current by a custom step' do
35
+ bar.inc(10)
36
+ expect(bar.current).to eq(10)
37
+ end
38
+
39
+ it 'clamps current to total' do
40
+ bar.inc(200)
41
+ expect(bar.current).to eq(100)
42
+ end
43
+ end
44
+
45
+ describe '#set' do
46
+ it 'sets current to the given value' do
47
+ bar.set(42)
48
+ expect(bar.current).to eq(42)
49
+ end
50
+
51
+ it 'raises on negative value' do
52
+ expect { bar.set(-1) }.to raise_error(RuntimeError)
53
+ end
54
+
55
+ it 'raises when value exceeds total' do
56
+ expect { bar.set(101) }.to raise_error(RuntimeError)
57
+ end
58
+ end
59
+
60
+ describe '#finish' do
61
+ it 'sets current to total and marks finished' do
62
+ bar.finish
63
+ expect(bar.current).to eq(100)
64
+ expect(bar.finished?).to be true
65
+ end
66
+ end
67
+
68
+ describe '#halt' do
69
+ it 'marks finished without changing current' do
70
+ bar.inc(30)
71
+ bar.halt
72
+ expect(bar.finished?).to be true
73
+ expect(bar.current).to eq(30)
74
+ end
75
+ end
76
+
77
+ describe '#inspect' do
78
+ it 'includes current/total' do
79
+ expect(bar.inspect).to include('0/100')
80
+ end
81
+ end
82
+
83
+ describe '#file_transfer_mode' do
84
+ it 'switches format_arguments to stat_for_file_transfer' do
85
+ bar.file_transfer_mode
86
+ expect(bar.instance_variable_get(:@format_arguments)).to include(:stat_for_file_transfer)
87
+ end
88
+ end
89
+
90
+ describe '#format=' do
91
+ it 'updates the format string' do
92
+ bar.format = "custom %s"
93
+ expect(bar.instance_variable_get(:@format)).to eq("custom %s")
94
+ end
95
+ end
96
+
97
+ describe '#format_arguments=' do
98
+ it 'updates format arguments' do
99
+ bar.format_arguments = [:title, :percentage]
100
+ expect(bar.instance_variable_get(:@format_arguments)).to eq([:title, :percentage])
101
+ end
102
+ end
103
+
104
+ describe 'convert_bytes (via fmt_stat_for_file_transfer)' do
105
+ it 'renders correctly in file_transfer_mode after finishing' do
106
+ bar.file_transfer_mode
107
+ bar.inc(50)
108
+ bar.finish
109
+ expect(out.string).not_to be_empty
110
+ end
111
+
112
+ it 'renders KB range correctly' do
113
+ b = described_class.new("KB", 2048, out)
114
+ b.file_transfer_mode
115
+ b.inc(2048)
116
+ b.finish
117
+ expect(out.string).to match(/KB/)
118
+ end
119
+
120
+ it 'renders MB range correctly' do
121
+ b = described_class.new("MB", 2 * 1024 * 1024, out)
122
+ b.file_transfer_mode
123
+ b.inc(2 * 1024 * 1024)
124
+ b.finish
125
+ expect(out.string).to match(/MB/)
126
+ end
127
+ end
128
+
129
+ describe 'zero total' do
130
+ subject(:bar) { described_class.new("Empty", 0, out) }
131
+
132
+ it 'does not raise on init' do
133
+ expect { bar }.not_to raise_error
134
+ end
135
+
136
+ it 'finish does not raise' do
137
+ expect { bar.finish }.not_to raise_error
138
+ end
139
+ end
140
+ end
141
+
142
+ # ── ReversedBar ──────────────────────────────────────────────────────────────
143
+
144
+ describe Tapsoob::Progress::ReversedBar do
145
+ subject(:bar) { described_class.new("Rev", 100, out) }
146
+
147
+ it 'inherits from Bar' do
148
+ expect(bar).to be_a(Tapsoob::Progress::Bar)
149
+ end
150
+
151
+ it 'reports inverted percentage internally (100 at start)' do
152
+ # do_percentage is private; test via inspect indirectly - just ensure no raise
153
+ expect { bar.inc(10) }.not_to raise_error
154
+ end
155
+ end
156
+
157
+ # ── ThreadSafeBar ────────────────────────────────────────────────────────────
158
+
159
+ describe Tapsoob::Progress::ThreadSafeBar do
160
+ let(:multi) do
161
+ mb = instance_double(Tapsoob::Progress::MultiBar)
162
+ allow(mb).to receive(:update)
163
+ allow(mb).to receive(:finish_bar)
164
+ allow(mb).to receive(:max_title_width).and_return(14)
165
+ mb
166
+ end
167
+
168
+ subject(:bar) { described_class.new("Worker", 50, multi) }
169
+
170
+ describe '#initialize' do
171
+ it 'stores title and total' do
172
+ expect(bar.title).to eq("Worker")
173
+ expect(bar.total).to eq(50)
174
+ end
175
+ end
176
+
177
+ describe '#inc' do
178
+ it 'increments current and notifies multi' do
179
+ expect(multi).to receive(:update)
180
+ bar.inc(5)
181
+ expect(bar.current).to eq(5)
182
+ end
183
+ end
184
+
185
+ describe '#finish' do
186
+ it 'sets current to total and calls finish_bar on multi' do
187
+ expect(multi).to receive(:finish_bar).with(bar)
188
+ bar.finish
189
+ expect(bar.current).to eq(50)
190
+ end
191
+ end
192
+
193
+ describe '#mark_finished / #finished?' do
194
+ it 'returns true after mark_finished' do
195
+ bar.mark_finished
196
+ expect(bar.finished?).to be true
197
+ end
198
+ end
199
+
200
+ describe '#render_to' do
201
+ it 'writes something to the given output' do
202
+ io = StringIO.new
203
+ bar.render_to(io)
204
+ expect(io.string).not_to be_empty
205
+ end
206
+ end
207
+
208
+ describe '#clear' do
209
+ it 'is a no-op (does not raise)' do
210
+ expect { bar.send(:clear) }.not_to raise_error
211
+ end
212
+ end
213
+ end
214
+
215
+ # ── MultiBar ─────────────────────────────────────────────────────────────────
216
+
217
+ describe Tapsoob::Progress::MultiBar do
218
+ subject(:multi) { described_class.new(2) }
219
+
220
+ describe '#initialize' do
221
+ it 'starts with no bars' do
222
+ expect(multi.instance_variable_get(:@bars)).to be_empty
223
+ end
224
+ end
225
+
226
+ describe '#create_bar' do
227
+ it 'returns a ThreadSafeBar' do
228
+ bar = multi.create_bar("Table1", 100)
229
+ expect(bar).to be_a(Tapsoob::Progress::ThreadSafeBar)
230
+ end
231
+
232
+ it 'does not create duplicate bars for the same title' do
233
+ multi.create_bar("Table1", 100)
234
+ multi.create_bar("Table1", 200)
235
+ bars = multi.instance_variable_get(:@bars)
236
+ expect(bars.count { |b| b.title == "Table1" }).to eq(1)
237
+ end
238
+ end
239
+
240
+ describe '#set_info' do
241
+ it 'does not raise' do
242
+ expect { multi.set_info("Processing...") }.not_to raise_error
243
+ end
244
+ end
245
+
246
+ describe '#stop' do
247
+ it 'deactivates the multi bar' do
248
+ multi.stop
249
+ expect(multi.instance_variable_get(:@active)).to be false
250
+ end
251
+
252
+ it 'is idempotent' do
253
+ multi.stop
254
+ expect { multi.stop }.not_to raise_error
255
+ end
256
+ end
257
+
258
+ describe '#max_title_width' do
259
+ it 'grows to accommodate longer titles' do
260
+ multi.create_bar("ShortTitle", 10)
261
+ multi.create_bar("A Much Longer Title Here", 10)
262
+ expect(multi.max_title_width).to be >= "A Much Longer Title Here".length
263
+ end
264
+ end
265
+
266
+ # ── rendering paths (drive render_active_display / redraw_all) ──────────────
267
+
268
+ describe '#update' do
269
+ it 'does not raise when active and initialized with bars' do
270
+ out = StringIO.new
271
+ multi.instance_variable_set(:@out, out)
272
+ multi.instance_variable_set(:@initialized, true)
273
+ multi.instance_variable_set(:@total_lines, 4)
274
+ multi.instance_variable_set(:@last_update, Time.now - 1)
275
+ bar = multi.create_bar("UpdateTable", 10)
276
+ expect { multi.update }.not_to raise_error
277
+ end
278
+
279
+ it 'is a no-op when @active is false' do
280
+ multi.stop
281
+ expect { multi.update }.not_to raise_error
282
+ end
283
+ end
284
+
285
+ describe '#finish_bar' do
286
+ it 'marks the bar as finished without raising' do
287
+ out = StringIO.new
288
+ multi.instance_variable_set(:@out, out)
289
+ multi.instance_variable_set(:@initialized, true)
290
+ multi.instance_variable_set(:@total_lines, 4)
291
+ multi.instance_variable_set(:@last_update, Time.now - 1)
292
+ bar = multi.create_bar("FinishTable", 5)
293
+ expect { multi.finish_bar(bar) }.not_to raise_error
294
+ expect(bar.instance_variable_get(:@finished_p)).to be true
295
+ end
296
+ end
297
+
298
+ describe '#set_info (with rendering)' do
299
+ it 'triggers redraw when initialized and a bar exists' do
300
+ out = StringIO.new
301
+ multi.instance_variable_set(:@out, out)
302
+ multi.instance_variable_set(:@initialized, true)
303
+ multi.instance_variable_set(:@total_lines, 4)
304
+ multi.instance_variable_set(:@last_update, Time.now - 1)
305
+ multi.create_bar("InfoTable", 10)
306
+ expect { multi.set_info("doing things") }.not_to raise_error
307
+ end
308
+ end
309
+
310
+ describe '#stop (initialized)' do
311
+ it 'clears terminal lines when previously initialized' do
312
+ out = StringIO.new
313
+ multi.instance_variable_set(:@out, out)
314
+ multi.instance_variable_set(:@initialized, true)
315
+ multi.instance_variable_set(:@total_lines, 4)
316
+ multi.stop
317
+ expect(out.string).to include("\e[")
318
+ end
319
+ end
320
+
321
+ describe 'render_active_display (via update)' do
322
+ it 'writes escape sequences to output when bars exist' do
323
+ out = StringIO.new
324
+ multi.instance_variable_set(:@out, out)
325
+ multi.instance_variable_set(:@initialized, true)
326
+ multi.instance_variable_set(:@total_lines, 4)
327
+ multi.instance_variable_set(:@last_update, Time.now - 1)
328
+ multi.instance_variable_set(:@info_message, "some info")
329
+ multi.create_bar("RenderTable", 10)
330
+ multi.update
331
+ expect(out.string).not_to be_empty
332
+ end
333
+ end
334
+ end
335
+ end