csvops 0.5.0.alpha → 0.6.0.alpha

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -3
  3. data/docs/architecture.md +61 -4
  4. data/docs/release-v0.6.0-alpha.md +84 -0
  5. data/lib/csvtool/application/use_cases/run_csv_parity.rb +70 -0
  6. data/lib/csvtool/cli.rb +5 -1
  7. data/lib/csvtool/domain/csv_parity_session/parity_options.rb +22 -0
  8. data/lib/csvtool/domain/csv_parity_session/parity_session.rb +20 -0
  9. data/lib/csvtool/domain/csv_parity_session/source_pair.rb +19 -0
  10. data/lib/csvtool/infrastructure/csv/csv_parity_comparator.rb +71 -0
  11. data/lib/csvtool/interface/cli/errors/presenter.rb +4 -0
  12. data/lib/csvtool/interface/cli/menu_loop.rb +5 -2
  13. data/lib/csvtool/interface/cli/workflows/builders/csv_parity_session_builder.rb +33 -0
  14. data/lib/csvtool/interface/cli/workflows/presenters/csv_parity_presenter.rb +38 -0
  15. data/lib/csvtool/interface/cli/workflows/run_csv_parity_workflow.rb +66 -0
  16. data/lib/csvtool/interface/cli/workflows/steps/parity/build_session_step.rb +25 -0
  17. data/lib/csvtool/interface/cli/workflows/steps/parity/collect_inputs_step.rb +32 -0
  18. data/lib/csvtool/interface/cli/workflows/steps/parity/execute_step.rb +26 -0
  19. data/lib/csvtool/version.rb +1 -1
  20. data/test/csvtool/application/use_cases/run_csv_parity_test.rb +160 -0
  21. data/test/csvtool/cli_test.rb +175 -21
  22. data/test/csvtool/cli_unit_test.rb +4 -4
  23. data/test/csvtool/domain/csv_parity_session/parity_options_test.rb +17 -0
  24. data/test/csvtool/domain/csv_parity_session/parity_session_test.rb +18 -0
  25. data/test/csvtool/domain/csv_parity_session/source_pair_test.rb +11 -0
  26. data/test/csvtool/infrastructure/csv/csv_parity_comparator_test.rb +78 -0
  27. data/test/csvtool/interface/cli/errors/presenter_test.rb +2 -0
  28. data/test/csvtool/interface/cli/menu_loop_test.rb +59 -16
  29. data/test/csvtool/interface/cli/workflows/builders/csv_parity_session_builder_test.rb +20 -0
  30. data/test/csvtool/interface/cli/workflows/presenters/csv_parity_presenter_test.rb +43 -0
  31. data/test/csvtool/interface/cli/workflows/run_csv_parity_workflow_test.rb +94 -0
  32. data/test/csvtool/interface/cli/workflows/steps/parity/build_session_step_test.rb +41 -0
  33. data/test/csvtool/interface/cli/workflows/steps/parity/collect_inputs_step_test.rb +30 -0
  34. data/test/csvtool/interface/cli/workflows/steps/parity/execute_step_test.rb +40 -0
  35. data/test/fixtures/parity_duplicates_left.csv +4 -0
  36. data/test/fixtures/parity_duplicates_right.csv +3 -0
  37. data/test/fixtures/parity_people_header_mismatch.csv +4 -0
  38. data/test/fixtures/parity_people_many_reordered.csv +13 -0
  39. data/test/fixtures/parity_people_mismatch.csv +4 -0
  40. data/test/fixtures/parity_people_reordered.csv +4 -0
  41. data/test/fixtures/parity_people_reordered.tsv +4 -0
  42. metadata +31 -1
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csvtool/application/use_cases/run_csv_parity"
4
+ require "csvtool/interface/cli/errors/presenter"
5
+ require "csvtool/interface/cli/prompts/file_path_prompt"
6
+ require "csvtool/interface/cli/prompts/separator_prompt"
7
+ require "csvtool/interface/cli/prompts/headers_present_prompt"
8
+ require "csvtool/interface/cli/workflows/builders/csv_parity_session_builder"
9
+ require "csvtool/interface/cli/workflows/presenters/csv_parity_presenter"
10
+ require "csvtool/interface/cli/workflows/support/result_error_handler"
11
+ require "csvtool/interface/cli/workflows/steps/workflow_step_pipeline"
12
+ require "csvtool/interface/cli/workflows/steps/parity/collect_inputs_step"
13
+ require "csvtool/interface/cli/workflows/steps/parity/build_session_step"
14
+ require "csvtool/interface/cli/workflows/steps/parity/execute_step"
15
+
16
+ module Csvtool
17
+ module Interface
18
+ module CLI
19
+ module Workflows
20
+ class RunCsvParityWorkflow
21
+ def initialize(stdin:, stdout:, use_case: Application::UseCases::RunCsvParity.new)
22
+ @stdin = stdin
23
+ @stdout = stdout
24
+ @use_case = use_case
25
+ @errors = Interface::CLI::Errors::Presenter.new(stdout: stdout)
26
+ @session_builder = Builders::CsvParitySessionBuilder.new
27
+ @presenter = Presenters::CsvParityPresenter.new(stdout: stdout)
28
+ @result_error_handler = Support::ResultErrorHandler.new(errors: @errors)
29
+ end
30
+
31
+ def call
32
+ context = {
33
+ use_case: @use_case,
34
+ session_builder: @session_builder,
35
+ presenter: @presenter,
36
+ handle_error: method(:handle_error)
37
+ }
38
+ pipeline = Steps::WorkflowStepPipeline.new(steps: [
39
+ Steps::Parity::CollectInputsStep.new(
40
+ file_path_prompt: Interface::CLI::Prompts::FilePathPrompt.new(stdin: @stdin, stdout: @stdout),
41
+ separator_prompt: Interface::CLI::Prompts::SeparatorPrompt.new(stdin: @stdin, stdout: @stdout, errors: @errors),
42
+ headers_present_prompt: Interface::CLI::Prompts::HeadersPresentPrompt.new(stdin: @stdin, stdout: @stdout)
43
+ ),
44
+ Steps::Parity::BuildSessionStep.new,
45
+ Steps::Parity::ExecuteStep.new
46
+ ])
47
+ pipeline.call(context)
48
+ nil
49
+ end
50
+
51
+ private
52
+
53
+ def handle_error(result)
54
+ @result_error_handler.call(result, {
55
+ file_not_found: ->(r, errors) { errors.file_not_found(r.data[:path]) },
56
+ could_not_parse_csv: ->(_r, errors) { errors.could_not_parse_csv },
57
+ cannot_read_file: ->(r, errors) { errors.cannot_read_file(r.data[:path]) },
58
+ no_headers: ->(_r, errors) { errors.no_headers },
59
+ header_mismatch: ->(_r, errors) { errors.header_mismatch }
60
+ })
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Interface
5
+ module CLI
6
+ module Workflows
7
+ module Steps
8
+ module Parity
9
+ class BuildSessionStep
10
+ def call(context)
11
+ context[:session] = context.fetch(:session_builder).call(
12
+ left_path: context.fetch(:left_path),
13
+ right_path: context.fetch(:right_path),
14
+ col_sep: context.fetch(:col_sep),
15
+ headers_present: context.fetch(:headers_present)
16
+ )
17
+ nil
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Interface
5
+ module CLI
6
+ module Workflows
7
+ module Steps
8
+ module Parity
9
+ class CollectInputsStep
10
+ def initialize(file_path_prompt:, separator_prompt:, headers_present_prompt:)
11
+ @file_path_prompt = file_path_prompt
12
+ @separator_prompt = separator_prompt
13
+ @headers_present_prompt = headers_present_prompt
14
+ end
15
+
16
+ def call(context)
17
+ context[:left_path] = @file_path_prompt.call(label: "Left CSV file path: ")
18
+ context[:right_path] = @file_path_prompt.call(label: "Right CSV file path: ")
19
+ col_sep = @separator_prompt.call
20
+ return :halt if col_sep.nil?
21
+
22
+ context[:col_sep] = col_sep
23
+ context[:headers_present] = @headers_present_prompt.call
24
+ nil
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Csvtool
4
+ module Interface
5
+ module CLI
6
+ module Workflows
7
+ module Steps
8
+ module Parity
9
+ class ExecuteStep
10
+ def call(context)
11
+ result = context.fetch(:use_case).call(session: context.fetch(:session))
12
+ unless result.ok?
13
+ context.fetch(:handle_error).call(result)
14
+ return :halt
15
+ end
16
+
17
+ context.fetch(:presenter).print_summary(result.data)
18
+ nil
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Csvtool
4
- VERSION = "0.5.0.alpha"
4
+ VERSION = "0.6.0.alpha"
5
5
  end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../test_helper"
4
+ require "csvtool/application/use_cases/run_csv_parity"
5
+ require "csvtool/domain/csv_parity_session/source_pair"
6
+ require "csvtool/domain/csv_parity_session/parity_options"
7
+ require "csvtool/domain/csv_parity_session/parity_session"
8
+
9
+ class RunCsvParityTest < Minitest::Test
10
+ class EaccesComparator
11
+ def call(left_path:, right_path:, col_sep:, headers_present:, sample_limit: 5)
12
+ error = Errno::EACCES.new("/tmp/protected.csv")
13
+ def error.path
14
+ "/tmp/protected.csv"
15
+ end
16
+ raise error
17
+ end
18
+ end
19
+
20
+ def fixture_path(name)
21
+ File.expand_path("../../../fixtures/#{name}", __dir__)
22
+ end
23
+
24
+ def build_session(left_path:, right_path:, separator: ",", headers_present: true)
25
+ source_pair = Csvtool::Domain::CsvParitySession::SourcePair.new(
26
+ left_path: left_path,
27
+ right_path: right_path
28
+ )
29
+ options = Csvtool::Domain::CsvParitySession::ParityOptions.new(
30
+ separator: separator,
31
+ headers_present: headers_present
32
+ )
33
+ Csvtool::Domain::CsvParitySession::ParitySession.start(
34
+ source_pair: source_pair,
35
+ options: options
36
+ )
37
+ end
38
+
39
+ def test_returns_match_for_equivalent_files
40
+ result = Csvtool::Application::UseCases::RunCsvParity.new.call(
41
+ session: build_session(
42
+ left_path: fixture_path("sample_people.csv"),
43
+ right_path: fixture_path("parity_people_reordered.csv")
44
+ )
45
+ )
46
+
47
+ assert_equal true, result.ok?
48
+ assert_equal true, result.data[:match]
49
+ assert_equal 0, result.data[:left_only_count]
50
+ assert_equal 0, result.data[:right_only_count]
51
+ end
52
+
53
+ def test_returns_mismatch_counts_for_non_equivalent_files
54
+ result = Csvtool::Application::UseCases::RunCsvParity.new.call(
55
+ session: build_session(
56
+ left_path: fixture_path("sample_people.csv"),
57
+ right_path: fixture_path("parity_people_mismatch.csv")
58
+ )
59
+ )
60
+
61
+ assert_equal true, result.ok?
62
+ assert_equal false, result.data[:match]
63
+ assert_equal 1, result.data[:left_only_count]
64
+ assert_equal 1, result.data[:right_only_count]
65
+ end
66
+
67
+ def test_duplicate_count_differences_are_detected
68
+ result = Csvtool::Application::UseCases::RunCsvParity.new.call(
69
+ session: build_session(
70
+ left_path: fixture_path("parity_duplicates_left.csv"),
71
+ right_path: fixture_path("parity_duplicates_right.csv")
72
+ )
73
+ )
74
+
75
+ assert_equal true, result.ok?
76
+ assert_equal false, result.data[:match]
77
+ assert_equal 1, result.data[:left_only_count]
78
+ assert_equal 0, result.data[:right_only_count]
79
+ assert_equal "1,Alice", result.data[:left_only_examples][0][:row]
80
+ assert_equal 1, result.data[:left_only_examples][0][:count_delta]
81
+ end
82
+
83
+ def test_headered_mode_fails_when_headers_do_not_match
84
+ result = Csvtool::Application::UseCases::RunCsvParity.new.call(
85
+ session: build_session(
86
+ left_path: fixture_path("sample_people.csv"),
87
+ right_path: fixture_path("parity_people_header_mismatch.csv")
88
+ )
89
+ )
90
+
91
+ assert_equal false, result.ok?
92
+ assert_equal :header_mismatch, result.error
93
+ end
94
+
95
+ def test_headerless_mode_compares_all_rows_as_data
96
+ result = Csvtool::Application::UseCases::RunCsvParity.new.call(
97
+ session: build_session(
98
+ left_path: fixture_path("sample_people_no_headers.csv"),
99
+ right_path: fixture_path("sample_people_no_headers.csv"),
100
+ headers_present: false
101
+ )
102
+ )
103
+
104
+ assert_equal true, result.ok?
105
+ assert_equal true, result.data[:match]
106
+ end
107
+
108
+ def test_returns_file_not_found_for_left_side
109
+ result = Csvtool::Application::UseCases::RunCsvParity.new.call(
110
+ session: build_session(
111
+ left_path: "/tmp/nope-left.csv",
112
+ right_path: fixture_path("sample_people.csv")
113
+ )
114
+ )
115
+
116
+ assert_equal false, result.ok?
117
+ assert_equal :file_not_found, result.error
118
+ assert_equal "/tmp/nope-left.csv", result.data[:path]
119
+ end
120
+
121
+ def test_returns_file_not_found_for_right_side
122
+ result = Csvtool::Application::UseCases::RunCsvParity.new.call(
123
+ session: build_session(
124
+ left_path: fixture_path("sample_people.csv"),
125
+ right_path: "/tmp/nope-right.csv"
126
+ )
127
+ )
128
+
129
+ assert_equal false, result.ok?
130
+ assert_equal :file_not_found, result.error
131
+ assert_equal "/tmp/nope-right.csv", result.data[:path]
132
+ end
133
+
134
+ def test_returns_parse_error_for_malformed_csv
135
+ result = Csvtool::Application::UseCases::RunCsvParity.new.call(
136
+ session: build_session(
137
+ left_path: fixture_path("sample_people.csv"),
138
+ right_path: fixture_path("sample_people_bad_tail.csv")
139
+ )
140
+ )
141
+
142
+ assert_equal false, result.ok?
143
+ assert_equal :could_not_parse_csv, result.error
144
+ end
145
+
146
+ def test_returns_cannot_read_file_when_eacces_is_raised
147
+ result = Csvtool::Application::UseCases::RunCsvParity.new(
148
+ comparator: EaccesComparator.new
149
+ ).call(
150
+ session: build_session(
151
+ left_path: fixture_path("sample_people.csv"),
152
+ right_path: fixture_path("sample_people.csv")
153
+ )
154
+ )
155
+
156
+ assert_equal false, result.ok?
157
+ assert_equal :cannot_read_file, result.error
158
+ assert_equal "/tmp/protected.csv", result.data[:path]
159
+ end
160
+ end
@@ -11,7 +11,7 @@ class TestCli < Minitest::Test
11
11
 
12
12
  def test_menu_can_exit_cleanly
13
13
  output = StringIO.new
14
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new("5\n"), stdout: output, stderr: StringIO.new)
14
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new("6\n"), stdout: output, stderr: StringIO.new)
15
15
  assert_equal 0, status
16
16
  assert_includes output.string, "CSV Tool Menu"
17
17
  end
@@ -26,7 +26,7 @@ class TestCli < Minitest::Test
26
26
  "",
27
27
  "y",
28
28
  "",
29
- "5"
29
+ "6"
30
30
  ].join("\n") + "\n"
31
31
 
32
32
  output = StringIO.new
@@ -58,7 +58,7 @@ class TestCli < Minitest::Test
58
58
  "2",
59
59
  "3",
60
60
  "",
61
- "5"
61
+ "6"
62
62
  ].join("\n") + "\n"
63
63
 
64
64
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -79,7 +79,7 @@ class TestCli < Minitest::Test
79
79
  "0",
80
80
  "3",
81
81
  "",
82
- "5"
82
+ "6"
83
83
  ].join("\n") + "\n"
84
84
 
85
85
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -98,7 +98,7 @@ class TestCli < Minitest::Test
98
98
  "2",
99
99
  "3",
100
100
  "",
101
- "5"
101
+ "6"
102
102
  ].join("\n") + "\n"
103
103
 
104
104
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -119,7 +119,7 @@ class TestCli < Minitest::Test
119
119
  "2",
120
120
  "3",
121
121
  "",
122
- "5"
122
+ "6"
123
123
  ].join("\n") + "\n"
124
124
 
125
125
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -144,7 +144,7 @@ class TestCli < Minitest::Test
144
144
  "3",
145
145
  "2",
146
146
  output_path,
147
- "5"
147
+ "6"
148
148
  ].join("\n") + "\n"
149
149
 
150
150
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -164,7 +164,7 @@ class TestCli < Minitest::Test
164
164
  "1",
165
165
  "2",
166
166
  "",
167
- "5"
167
+ "6"
168
168
  ].join("\n") + "\n"
169
169
 
170
170
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -184,7 +184,7 @@ class TestCli < Minitest::Test
184
184
  "",
185
185
  "",
186
186
  "",
187
- "5"
187
+ "6"
188
188
  ].join("\n") + "\n"
189
189
 
190
190
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -209,7 +209,7 @@ class TestCli < Minitest::Test
209
209
  "",
210
210
  "2",
211
211
  output_path,
212
- "5"
212
+ "6"
213
213
  ].join("\n") + "\n"
214
214
 
215
215
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -231,7 +231,7 @@ class TestCli < Minitest::Test
231
231
  "",
232
232
  "",
233
233
  "",
234
- "5"
234
+ "6"
235
235
  ].join("\n") + "\n"
236
236
 
237
237
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -250,7 +250,7 @@ class TestCli < Minitest::Test
250
250
  "n",
251
251
  "",
252
252
  "",
253
- "5"
253
+ "6"
254
254
  ].join("\n") + "\n"
255
255
 
256
256
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -270,7 +270,7 @@ class TestCli < Minitest::Test
270
270
  "",
271
271
  "",
272
272
  "abc",
273
- "5"
273
+ "6"
274
274
  ].join("\n") + "\n"
275
275
 
276
276
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -295,7 +295,7 @@ class TestCli < Minitest::Test
295
295
  "",
296
296
  "",
297
297
  "",
298
- "5"
298
+ "6"
299
299
  ].join("\n") + "\n"
300
300
 
301
301
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -329,7 +329,7 @@ class TestCli < Minitest::Test
329
329
  "",
330
330
  "2",
331
331
  output_path,
332
- "5"
332
+ "6"
333
333
  ].join("\n") + "\n"
334
334
 
335
335
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -356,7 +356,7 @@ class TestCli < Minitest::Test
356
356
  "",
357
357
  "",
358
358
  "",
359
- "5"
359
+ "6"
360
360
  ].join("\n") + "\n"
361
361
 
362
362
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -382,7 +382,7 @@ class TestCli < Minitest::Test
382
382
  "",
383
383
  "",
384
384
  "",
385
- "5"
385
+ "6"
386
386
  ].join("\n") + "\n"
387
387
 
388
388
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -394,6 +394,160 @@ class TestCli < Minitest::Test
394
394
  assert_includes output.string, "Summary: source_rows=5 removed_rows=3 kept_rows=2"
395
395
  end
396
396
 
397
+ def test_parity_workflow_reports_match_and_returns_to_menu
398
+ output = StringIO.new
399
+ input = [
400
+ "5",
401
+ fixture_path("sample_people.csv"),
402
+ fixture_path("sample_people.csv"),
403
+ "",
404
+ "",
405
+ "6"
406
+ ].join("\n") + "\n"
407
+
408
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
409
+
410
+ assert_equal 0, status
411
+ assert_includes output.string, "Left CSV file path:"
412
+ assert_includes output.string, "Right CSV file path:"
413
+ assert_includes output.string, "MATCH"
414
+ assert_includes output.string, "Summary: left_rows=3 right_rows=3 left_only=0 right_only=0"
415
+ assert_operator output.string.scan("CSV Tool Menu").length, :>=, 2
416
+ end
417
+
418
+ def test_parity_workflow_supports_tsv_separator
419
+ output = StringIO.new
420
+ input = [
421
+ "5",
422
+ fixture_path("sample_people.tsv"),
423
+ fixture_path("parity_people_reordered.tsv"),
424
+ "2",
425
+ "",
426
+ "6"
427
+ ].join("\n") + "\n"
428
+
429
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
430
+
431
+ assert_equal 0, status
432
+ assert_includes output.string, "MATCH"
433
+ assert_includes output.string, "Summary: left_rows=3 right_rows=3 left_only=0 right_only=0"
434
+ end
435
+
436
+ def test_parity_workflow_headerless_mode_compares_all_rows
437
+ output = StringIO.new
438
+ input = [
439
+ "5",
440
+ fixture_path("sample_people_no_headers.csv"),
441
+ fixture_path("sample_people_no_headers.csv"),
442
+ "",
443
+ "n",
444
+ "6"
445
+ ].join("\n") + "\n"
446
+
447
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
448
+
449
+ assert_equal 0, status
450
+ assert_includes output.string, "MATCH"
451
+ assert_includes output.string, "Summary: left_rows=3 right_rows=3 left_only=0 right_only=0"
452
+ end
453
+
454
+ def test_parity_workflow_reports_header_mismatch_in_headered_mode
455
+ output = StringIO.new
456
+ input = [
457
+ "5",
458
+ fixture_path("sample_people.csv"),
459
+ fixture_path("parity_people_header_mismatch.csv"),
460
+ "",
461
+ "",
462
+ "6"
463
+ ].join("\n") + "\n"
464
+
465
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
466
+
467
+ assert_equal 0, status
468
+ assert_includes output.string, "CSV headers do not match."
469
+ assert_operator output.string.scan("CSV Tool Menu").length, :>=, 2
470
+ end
471
+
472
+ def test_parity_workflow_prints_mismatch_examples_and_counts
473
+ output = StringIO.new
474
+ input = [
475
+ "5",
476
+ fixture_path("sample_people.csv"),
477
+ fixture_path("parity_people_mismatch.csv"),
478
+ "",
479
+ "",
480
+ "6"
481
+ ].join("\n") + "\n"
482
+
483
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
484
+
485
+ assert_equal 0, status
486
+ assert_includes output.string, "MISMATCH"
487
+ assert_includes output.string, "Summary: left_rows=3 right_rows=3 left_only=1 right_only=1"
488
+ assert_includes output.string, "Left-only examples:"
489
+ assert_includes output.string, "Cara,Berlin (count +1)"
490
+ assert_includes output.string, "Right-only examples:"
491
+ assert_includes output.string, "Dina,Rome (count +1)"
492
+ end
493
+
494
+ def test_parity_workflow_missing_left_file_returns_to_menu
495
+ output = StringIO.new
496
+ input = [
497
+ "5",
498
+ "/tmp/not-there-left.csv",
499
+ fixture_path("sample_people.csv"),
500
+ "",
501
+ "",
502
+ "6"
503
+ ].join("\n") + "\n"
504
+
505
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
506
+
507
+ assert_equal 0, status
508
+ assert_includes output.string, "File not found: /tmp/not-there-left.csv"
509
+ assert_operator output.string.scan("CSV Tool Menu").length, :>=, 2
510
+ refute_includes output.string, "Traceback"
511
+ end
512
+
513
+ def test_parity_workflow_missing_right_file_returns_to_menu
514
+ output = StringIO.new
515
+ input = [
516
+ "5",
517
+ fixture_path("sample_people.csv"),
518
+ "/tmp/not-there-right.csv",
519
+ "",
520
+ "",
521
+ "6"
522
+ ].join("\n") + "\n"
523
+
524
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
525
+
526
+ assert_equal 0, status
527
+ assert_includes output.string, "File not found: /tmp/not-there-right.csv"
528
+ assert_operator output.string.scan("CSV Tool Menu").length, :>=, 2
529
+ refute_includes output.string, "Traceback"
530
+ end
531
+
532
+ def test_parity_workflow_malformed_csv_returns_to_menu
533
+ output = StringIO.new
534
+ input = [
535
+ "5",
536
+ fixture_path("sample_people.csv"),
537
+ fixture_path("sample_people_bad_tail.csv"),
538
+ "",
539
+ "",
540
+ "6"
541
+ ].join("\n") + "\n"
542
+
543
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
544
+
545
+ assert_equal 0, status
546
+ assert_includes output.string, "Could not parse CSV file."
547
+ assert_operator output.string.scan("CSV Tool Menu").length, :>=, 2
548
+ refute_includes output.string, "Traceback"
549
+ end
550
+
397
551
  def test_end_to_end_file_output_writes_expected_csv
398
552
  output = StringIO.new
399
553
  output_path = nil
@@ -410,7 +564,7 @@ class TestCli < Minitest::Test
410
564
  "y",
411
565
  "2",
412
566
  output_path,
413
- "5"
567
+ "6"
414
568
  ].join("\n") + "\n"
415
569
 
416
570
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: output, stderr: StringIO.new)
@@ -430,7 +584,7 @@ class TestCli < Minitest::Test
430
584
  "1",
431
585
  "",
432
586
  "n",
433
- "5"
587
+ "6"
434
588
  ].join("\n") + "\n"
435
589
 
436
590
  output = StringIO.new
@@ -445,7 +599,7 @@ class TestCli < Minitest::Test
445
599
  output = StringIO.new
446
600
  status = Csvtool::CLI.start(
447
601
  ["menu"],
448
- stdin: StringIO.new("1\n/tmp/does-not-exist.csv\n4\n"),
602
+ stdin: StringIO.new("1\n/tmp/does-not-exist.csv\n4\n6\n"),
449
603
  stdout: output,
450
604
  stderr: StringIO.new
451
605
  )
@@ -466,7 +620,7 @@ class TestCli < Minitest::Test
466
620
  "y",
467
621
  "2",
468
622
  "/tmp/not-a-dir/out.csv",
469
- "5"
623
+ "6"
470
624
  ].join("\n") + "\n"
471
625
 
472
626
  output = StringIO.new