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
@@ -16,7 +16,7 @@ class CliUnitTest < Minitest::Test
16
16
  end
17
17
 
18
18
  def test_menu_command_can_exit_zero
19
- status = Csvtool::CLI.start(["menu"], stdin: StringIO.new("5\n"), stdout: StringIO.new, stderr: StringIO.new)
19
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new("6\n"), stdout: StringIO.new, stderr: StringIO.new)
20
20
  assert_equal 0, status
21
21
  end
22
22
 
@@ -28,7 +28,7 @@ class CliUnitTest < Minitest::Test
28
28
  def test_menu_routes_to_row_range_shell
29
29
  stdout = StringIO.new
30
30
  fixture = File.expand_path("../fixtures/sample_people.csv", __dir__)
31
- input = ["2", fixture, "", "2", "3", "", "5"].join("\n") + "\n"
31
+ input = ["2", fixture, "", "2", "3", "", "6"].join("\n") + "\n"
32
32
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: stdout, stderr: StringIO.new)
33
33
  assert_equal 0, status
34
34
  assert_includes stdout.string, "name,city"
@@ -39,7 +39,7 @@ class CliUnitTest < Minitest::Test
39
39
  def test_menu_routes_to_randomize_rows_shell
40
40
  stdout = StringIO.new
41
41
  fixture = File.expand_path("../fixtures/sample_people.csv", __dir__)
42
- input = ["3", fixture, "", "", "", "", "5"].join("\n") + "\n"
42
+ input = ["3", fixture, "", "", "", "", "6"].join("\n") + "\n"
43
43
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: stdout, stderr: StringIO.new)
44
44
  assert_equal 0, status
45
45
  assert_includes stdout.string, "name,city"
@@ -52,7 +52,7 @@ class CliUnitTest < Minitest::Test
52
52
  stdout = StringIO.new
53
53
  source_fixture = File.expand_path("../fixtures/dedupe_source.csv", __dir__)
54
54
  reference_fixture = File.expand_path("../fixtures/dedupe_reference.csv", __dir__)
55
- input = ["4", source_fixture, "", "", reference_fixture, "", "", "customer_id", "external_id", "", "", "", "5"].join("\n") + "\n"
55
+ input = ["4", source_fixture, "", "", reference_fixture, "", "", "customer_id", "external_id", "", "", "", "6"].join("\n") + "\n"
56
56
  status = Csvtool::CLI.start(["menu"], stdin: StringIO.new(input), stdout: stdout, stderr: StringIO.new)
57
57
  assert_equal 0, status
58
58
  assert_includes stdout.string, "customer_id,name"
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../test_helper"
4
+ require "csvtool/domain/csv_parity_session/parity_options"
5
+
6
+ class ParityOptionsTest < Minitest::Test
7
+ def test_requires_separator
8
+ assert_raises(ArgumentError) do
9
+ Csvtool::Domain::CsvParitySession::ParityOptions.new(separator: "", headers_present: true)
10
+ end
11
+ end
12
+
13
+ def test_exposes_headers_present
14
+ options = Csvtool::Domain::CsvParitySession::ParityOptions.new(separator: ",", headers_present: false)
15
+ assert_equal false, options.headers_present?
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../test_helper"
4
+ require "csvtool/domain/csv_parity_session/source_pair"
5
+ require "csvtool/domain/csv_parity_session/parity_options"
6
+ require "csvtool/domain/csv_parity_session/parity_session"
7
+
8
+ class ParitySessionTest < Minitest::Test
9
+ def test_stores_source_pair_and_options
10
+ source_pair = Csvtool::Domain::CsvParitySession::SourcePair.new(left_path: "/tmp/l.csv", right_path: "/tmp/r.csv")
11
+ options = Csvtool::Domain::CsvParitySession::ParityOptions.new(separator: ",", headers_present: true)
12
+
13
+ session = Csvtool::Domain::CsvParitySession::ParitySession.start(source_pair: source_pair, options: options)
14
+
15
+ assert_equal source_pair, session.source_pair
16
+ assert_equal options, session.options
17
+ end
18
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../test_helper"
4
+ require "csvtool/domain/csv_parity_session/source_pair"
5
+
6
+ class SourcePairTest < Minitest::Test
7
+ def test_requires_paths
8
+ assert_raises(ArgumentError) { Csvtool::Domain::CsvParitySession::SourcePair.new(left_path: "", right_path: "/tmp/r.csv") }
9
+ assert_raises(ArgumentError) { Csvtool::Domain::CsvParitySession::SourcePair.new(left_path: "/tmp/l.csv", right_path: "") }
10
+ end
11
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../test_helper"
4
+ require "csvtool/infrastructure/csv/csv_parity_comparator"
5
+
6
+ class CsvParityComparatorTest < Minitest::Test
7
+ def fixture_path(name)
8
+ File.expand_path("../../../fixtures/#{name}", __dir__)
9
+ end
10
+
11
+ def test_reports_match_when_rows_are_equal_ignoring_order
12
+ comparator = Csvtool::Infrastructure::CSV::CsvParityComparator.new
13
+
14
+ result = comparator.call(
15
+ left_path: fixture_path("sample_people.csv"),
16
+ right_path: fixture_path("parity_people_reordered.csv"),
17
+ col_sep: ",",
18
+ headers_present: true
19
+ )
20
+
21
+ assert_equal true, result[:match]
22
+ assert_equal 0, result[:left_only_count]
23
+ assert_equal 0, result[:right_only_count]
24
+ end
25
+
26
+ def test_reports_mismatch_counts_for_different_rows
27
+ comparator = Csvtool::Infrastructure::CSV::CsvParityComparator.new
28
+
29
+ result = comparator.call(
30
+ left_path: fixture_path("sample_people.csv"),
31
+ right_path: fixture_path("parity_people_mismatch.csv"),
32
+ col_sep: ",",
33
+ headers_present: true
34
+ )
35
+
36
+ assert_equal false, result[:match]
37
+ assert_equal 1, result[:left_only_count]
38
+ assert_equal 1, result[:right_only_count]
39
+ assert_equal "Cara,Berlin", result[:left_only_examples][0][:row]
40
+ assert_equal 1, result[:left_only_examples][0][:count_delta]
41
+ assert_equal "Dina,Rome", result[:right_only_examples][0][:row]
42
+ assert_equal 1, result[:right_only_examples][0][:count_delta]
43
+ end
44
+
45
+ def test_respects_duplicate_counts
46
+ comparator = Csvtool::Infrastructure::CSV::CsvParityComparator.new
47
+
48
+ result = comparator.call(
49
+ left_path: fixture_path("parity_duplicates_left.csv"),
50
+ right_path: fixture_path("parity_duplicates_right.csv"),
51
+ col_sep: ",",
52
+ headers_present: true
53
+ )
54
+
55
+ assert_equal false, result[:match]
56
+ assert_equal 1, result[:left_only_count]
57
+ assert_equal 0, result[:right_only_count]
58
+ assert_equal "1,Alice", result[:left_only_examples][0][:row]
59
+ assert_equal 1, result[:left_only_examples][0][:count_delta]
60
+ end
61
+
62
+ def test_preserves_exact_semantics_for_larger_fixture_with_different_order
63
+ comparator = Csvtool::Infrastructure::CSV::CsvParityComparator.new
64
+
65
+ result = comparator.call(
66
+ left_path: fixture_path("sample_people_many.csv"),
67
+ right_path: fixture_path("parity_people_many_reordered.csv"),
68
+ col_sep: ",",
69
+ headers_present: true
70
+ )
71
+
72
+ assert_equal true, result[:match]
73
+ assert_equal 12, result[:left_rows]
74
+ assert_equal 12, result[:right_rows]
75
+ assert_equal 0, result[:left_only_count]
76
+ assert_equal 0, result[:right_only_count]
77
+ end
78
+ end
@@ -24,6 +24,7 @@ class ErrorsPresenterTest < Minitest::Test
24
24
  presenter.invalid_end_row
25
25
  presenter.invalid_row_range_order
26
26
  presenter.row_range_out_of_bounds(3)
27
+ presenter.header_mismatch
27
28
 
28
29
  text = out.string
29
30
  assert_includes text, "File not found: /tmp/x.csv"
@@ -42,5 +43,6 @@ class ErrorsPresenterTest < Minitest::Test
42
43
  assert_includes text, "End row must be a positive integer."
43
44
  assert_includes text, "End row must be greater than or equal to start row."
44
45
  assert_includes text, "Row range is out of bounds. File has 3 data rows."
46
+ assert_includes text, "CSV headers do not match."
45
47
  end
46
48
  end
@@ -21,15 +21,17 @@ class MenuLoopTest < Minitest::Test
21
21
  rows_action = FakeAction.new
22
22
  randomize_rows_action = FakeAction.new
23
23
  dedupe_action = FakeAction.new
24
+ parity_action = FakeAction.new
24
25
  stdout = StringIO.new
25
26
  menu = Csvtool::Interface::CLI::MenuLoop.new(
26
- stdin: StringIO.new("1\n5\n"),
27
+ stdin: StringIO.new("1\n6\n"),
27
28
  stdout: stdout,
28
- menu_options: ["Extract column", "Extract rows (range)", "Randomize rows", "Dedupe using another CSV", "Exit"],
29
+ menu_options: ["Extract column", "Extract rows (range)", "Randomize rows", "Dedupe using another CSV", "Validate parity", "Exit"],
29
30
  extract_column_action: column_action,
30
31
  extract_rows_action: rows_action,
31
32
  randomize_rows_action: randomize_rows_action,
32
- dedupe_action: dedupe_action
33
+ dedupe_action: dedupe_action,
34
+ parity_action: parity_action
33
35
  )
34
36
 
35
37
  status = menu.run
@@ -39,6 +41,7 @@ class MenuLoopTest < Minitest::Test
39
41
  assert_equal 0, rows_action.runs
40
42
  assert_equal 0, randomize_rows_action.runs
41
43
  assert_equal 0, dedupe_action.runs
44
+ assert_equal 0, parity_action.runs
42
45
  assert_includes stdout.string, "CSV Tool Menu"
43
46
  end
44
47
 
@@ -47,15 +50,17 @@ class MenuLoopTest < Minitest::Test
47
50
  rows_action = FakeAction.new
48
51
  randomize_rows_action = FakeAction.new
49
52
  dedupe_action = FakeAction.new
53
+ parity_action = FakeAction.new
50
54
  stdout = StringIO.new
51
55
  menu = Csvtool::Interface::CLI::MenuLoop.new(
52
- stdin: StringIO.new("2\n5\n"),
56
+ stdin: StringIO.new("2\n6\n"),
53
57
  stdout: stdout,
54
- menu_options: ["Extract column", "Extract rows (range)", "Randomize rows", "Dedupe using another CSV", "Exit"],
58
+ menu_options: ["Extract column", "Extract rows (range)", "Randomize rows", "Dedupe using another CSV", "Validate parity", "Exit"],
55
59
  extract_column_action: column_action,
56
60
  extract_rows_action: rows_action,
57
61
  randomize_rows_action: randomize_rows_action,
58
- dedupe_action: dedupe_action
62
+ dedupe_action: dedupe_action,
63
+ parity_action: parity_action
59
64
  )
60
65
 
61
66
  status = menu.run
@@ -65,6 +70,7 @@ class MenuLoopTest < Minitest::Test
65
70
  assert_equal 1, rows_action.runs
66
71
  assert_equal 0, randomize_rows_action.runs
67
72
  assert_equal 0, dedupe_action.runs
73
+ assert_equal 0, parity_action.runs
68
74
  end
69
75
 
70
76
  def test_routes_randomize_rows_then_exit
@@ -72,15 +78,17 @@ class MenuLoopTest < Minitest::Test
72
78
  rows_action = FakeAction.new
73
79
  randomize_rows_action = FakeAction.new
74
80
  dedupe_action = FakeAction.new
81
+ parity_action = FakeAction.new
75
82
  stdout = StringIO.new
76
83
  menu = Csvtool::Interface::CLI::MenuLoop.new(
77
- stdin: StringIO.new("3\n5\n"),
84
+ stdin: StringIO.new("3\n6\n"),
78
85
  stdout: stdout,
79
- menu_options: ["Extract column", "Extract rows (range)", "Randomize rows", "Dedupe using another CSV", "Exit"],
86
+ menu_options: ["Extract column", "Extract rows (range)", "Randomize rows", "Dedupe using another CSV", "Validate parity", "Exit"],
80
87
  extract_column_action: column_action,
81
88
  extract_rows_action: rows_action,
82
89
  randomize_rows_action: randomize_rows_action,
83
- dedupe_action: dedupe_action
90
+ dedupe_action: dedupe_action,
91
+ parity_action: parity_action
84
92
  )
85
93
 
86
94
  status = menu.run
@@ -90,6 +98,7 @@ class MenuLoopTest < Minitest::Test
90
98
  assert_equal 0, rows_action.runs
91
99
  assert_equal 1, randomize_rows_action.runs
92
100
  assert_equal 0, dedupe_action.runs
101
+ assert_equal 0, parity_action.runs
93
102
  end
94
103
 
95
104
  def test_routes_dedupe_then_exit
@@ -97,15 +106,17 @@ class MenuLoopTest < Minitest::Test
97
106
  rows_action = FakeAction.new
98
107
  randomize_rows_action = FakeAction.new
99
108
  dedupe_action = FakeAction.new
109
+ parity_action = FakeAction.new
100
110
  stdout = StringIO.new
101
111
  menu = Csvtool::Interface::CLI::MenuLoop.new(
102
- stdin: StringIO.new("4\n5\n"),
112
+ stdin: StringIO.new("4\n6\n"),
103
113
  stdout: stdout,
104
- menu_options: ["Extract column", "Extract rows (range)", "Randomize rows", "Dedupe using another CSV", "Exit"],
114
+ menu_options: ["Extract column", "Extract rows (range)", "Randomize rows", "Dedupe using another CSV", "Validate parity", "Exit"],
105
115
  extract_column_action: column_action,
106
116
  extract_rows_action: rows_action,
107
117
  randomize_rows_action: randomize_rows_action,
108
- dedupe_action: dedupe_action
118
+ dedupe_action: dedupe_action,
119
+ parity_action: parity_action
109
120
  )
110
121
 
111
122
  status = menu.run
@@ -115,6 +126,35 @@ class MenuLoopTest < Minitest::Test
115
126
  assert_equal 0, rows_action.runs
116
127
  assert_equal 0, randomize_rows_action.runs
117
128
  assert_equal 1, dedupe_action.runs
129
+ assert_equal 0, parity_action.runs
130
+ end
131
+
132
+ def test_routes_parity_then_exit
133
+ column_action = FakeAction.new
134
+ rows_action = FakeAction.new
135
+ randomize_rows_action = FakeAction.new
136
+ dedupe_action = FakeAction.new
137
+ parity_action = FakeAction.new
138
+ stdout = StringIO.new
139
+ menu = Csvtool::Interface::CLI::MenuLoop.new(
140
+ stdin: StringIO.new("5\n6\n"),
141
+ stdout: stdout,
142
+ menu_options: ["Extract column", "Extract rows (range)", "Randomize rows", "Dedupe using another CSV", "Validate parity", "Exit"],
143
+ extract_column_action: column_action,
144
+ extract_rows_action: rows_action,
145
+ randomize_rows_action: randomize_rows_action,
146
+ dedupe_action: dedupe_action,
147
+ parity_action: parity_action
148
+ )
149
+
150
+ status = menu.run
151
+
152
+ assert_equal 0, status
153
+ assert_equal 0, column_action.runs
154
+ assert_equal 0, rows_action.runs
155
+ assert_equal 0, randomize_rows_action.runs
156
+ assert_equal 0, dedupe_action.runs
157
+ assert_equal 1, parity_action.runs
118
158
  end
119
159
 
120
160
  def test_invalid_choice_shows_prompt
@@ -122,23 +162,26 @@ class MenuLoopTest < Minitest::Test
122
162
  rows_action = FakeAction.new
123
163
  randomize_rows_action = FakeAction.new
124
164
  dedupe_action = FakeAction.new
165
+ parity_action = FakeAction.new
125
166
  stdout = StringIO.new
126
167
  menu = Csvtool::Interface::CLI::MenuLoop.new(
127
- stdin: StringIO.new("x\n5\n"),
168
+ stdin: StringIO.new("x\n6\n"),
128
169
  stdout: stdout,
129
- menu_options: ["Extract column", "Extract rows (range)", "Randomize rows", "Dedupe using another CSV", "Exit"],
170
+ menu_options: ["Extract column", "Extract rows (range)", "Randomize rows", "Dedupe using another CSV", "Validate parity", "Exit"],
130
171
  extract_column_action: column_action,
131
172
  extract_rows_action: rows_action,
132
173
  randomize_rows_action: randomize_rows_action,
133
- dedupe_action: dedupe_action
174
+ dedupe_action: dedupe_action,
175
+ parity_action: parity_action
134
176
  )
135
177
 
136
178
  menu.run
137
179
 
138
- assert_includes stdout.string, "Please choose 1, 2, 3, 4, or 5."
180
+ assert_includes stdout.string, "Please choose 1, 2, 3, 4, 5, or 6."
139
181
  assert_equal 0, column_action.runs
140
182
  assert_equal 0, rows_action.runs
141
183
  assert_equal 0, randomize_rows_action.runs
142
184
  assert_equal 0, dedupe_action.runs
185
+ assert_equal 0, parity_action.runs
143
186
  end
144
187
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/builders/csv_parity_session_builder"
5
+
6
+ class CsvParitySessionBuilderTest < Minitest::Test
7
+ def test_builds_parity_session
8
+ session = Csvtool::Interface::CLI::Workflows::Builders::CsvParitySessionBuilder.new.call(
9
+ left_path: "/tmp/left.csv",
10
+ right_path: "/tmp/right.csv",
11
+ col_sep: "\t",
12
+ headers_present: false
13
+ )
14
+
15
+ assert_equal "/tmp/left.csv", session.source_pair.left_path
16
+ assert_equal "/tmp/right.csv", session.source_pair.right_path
17
+ assert_equal "\t", session.options.separator
18
+ assert_equal false, session.options.headers_present?
19
+ end
20
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/presenters/csv_parity_presenter"
5
+
6
+ class CsvParityPresenterTest < Minitest::Test
7
+ def test_prints_match_summary
8
+ out = StringIO.new
9
+ presenter = Csvtool::Interface::CLI::Workflows::Presenters::CsvParityPresenter.new(stdout: out)
10
+
11
+ presenter.print_summary(
12
+ match: true,
13
+ left_rows: 3,
14
+ right_rows: 3,
15
+ left_only_count: 0,
16
+ right_only_count: 0
17
+ )
18
+
19
+ assert_includes out.string, "MATCH"
20
+ assert_includes out.string, "Summary: left_rows=3 right_rows=3 left_only=0 right_only=0"
21
+ end
22
+
23
+ def test_prints_mismatch_examples
24
+ out = StringIO.new
25
+ presenter = Csvtool::Interface::CLI::Workflows::Presenters::CsvParityPresenter.new(stdout: out)
26
+
27
+ presenter.print_summary(
28
+ match: false,
29
+ left_rows: 3,
30
+ right_rows: 3,
31
+ left_only_count: 1,
32
+ right_only_count: 1,
33
+ left_only_examples: [{ row: "Cara,Berlin", count_delta: 1 }],
34
+ right_only_examples: [{ row: "Dina,Rome", count_delta: 1 }]
35
+ )
36
+
37
+ assert_includes out.string, "MISMATCH"
38
+ assert_includes out.string, "Left-only examples:"
39
+ assert_includes out.string, "Cara,Berlin (count +1)"
40
+ assert_includes out.string, "Right-only examples:"
41
+ assert_includes out.string, "Dina,Rome (count +1)"
42
+ end
43
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/run_csv_parity_workflow"
5
+
6
+ class RunCsvParityWorkflowTest < Minitest::Test
7
+ class FakeUseCase
8
+ attr_reader :calls
9
+
10
+ def initialize
11
+ @calls = []
12
+ end
13
+
14
+ def call(session:)
15
+ @calls << session
16
+ Struct.new(:ok?, :data).new(true, {
17
+ match: true,
18
+ left_rows: 3,
19
+ right_rows: 3,
20
+ left_only_count: 0,
21
+ right_only_count: 0
22
+ })
23
+ end
24
+ end
25
+
26
+ class MismatchUseCase
27
+ def call(session:)
28
+ Struct.new(:ok?, :data).new(true, {
29
+ match: false,
30
+ left_rows: 3,
31
+ right_rows: 3,
32
+ left_only_count: 1,
33
+ right_only_count: 1,
34
+ left_only_examples: [{ row: "Cara,Berlin", count_delta: 1 }],
35
+ right_only_examples: [{ row: "Dina,Rome", count_delta: 1 }]
36
+ })
37
+ end
38
+ end
39
+
40
+ class CannotReadUseCase
41
+ def call(session:)
42
+ Struct.new(:ok?, :error, :data).new(false, :cannot_read_file, { path: "/tmp/protected.csv" })
43
+ end
44
+ end
45
+
46
+ def test_prompts_for_paths_and_calls_use_case
47
+ stdout = StringIO.new
48
+ use_case = FakeUseCase.new
49
+ input = StringIO.new("/tmp/left.csv\n/tmp/right.csv\n2\ny\n")
50
+
51
+ Csvtool::Interface::CLI::Workflows::RunCsvParityWorkflow
52
+ .new(stdin: input, stdout: stdout, use_case: use_case)
53
+ .call
54
+
55
+ call = use_case.calls.first
56
+ assert_equal "/tmp/left.csv", call.source_pair.left_path
57
+ assert_equal "/tmp/right.csv", call.source_pair.right_path
58
+ assert_equal "\t", call.options.separator
59
+ assert_equal true, call.options.headers_present?
60
+ assert_includes stdout.string, "Left CSV file path: "
61
+ assert_includes stdout.string, "Right CSV file path: "
62
+ assert_includes stdout.string, "Choose separator:"
63
+ assert_includes stdout.string, "Headers present? [Y/n]: "
64
+ assert_includes stdout.string, "MATCH"
65
+ assert_includes stdout.string, "Summary: left_rows=3 right_rows=3 left_only=0 right_only=0"
66
+ end
67
+
68
+ def test_prints_mismatch_examples_when_not_equal
69
+ stdout = StringIO.new
70
+ input = StringIO.new("/tmp/left.csv\n/tmp/right.csv\n\ny\n")
71
+
72
+ Csvtool::Interface::CLI::Workflows::RunCsvParityWorkflow
73
+ .new(stdin: input, stdout: stdout, use_case: MismatchUseCase.new)
74
+ .call
75
+
76
+ assert_includes stdout.string, "MISMATCH"
77
+ assert_includes stdout.string, "Left-only examples:"
78
+ assert_includes stdout.string, "Cara,Berlin (count +1)"
79
+ assert_includes stdout.string, "Right-only examples:"
80
+ assert_includes stdout.string, "Dina,Rome (count +1)"
81
+ end
82
+
83
+ def test_prints_cannot_read_error_without_stacktrace
84
+ stdout = StringIO.new
85
+ input = StringIO.new("/tmp/left.csv\n/tmp/right.csv\n\ny\n")
86
+
87
+ Csvtool::Interface::CLI::Workflows::RunCsvParityWorkflow
88
+ .new(stdin: input, stdout: stdout, use_case: CannotReadUseCase.new)
89
+ .call
90
+
91
+ assert_includes stdout.string, "Cannot read file: /tmp/protected.csv"
92
+ refute_includes stdout.string, "Traceback"
93
+ end
94
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/steps/parity/build_session_step"
5
+
6
+ class ParityBuildSessionStepTest < Minitest::Test
7
+ class FakeBuilder
8
+ attr_reader :args
9
+
10
+ def call(left_path:, right_path:, col_sep:, headers_present:)
11
+ @args = {
12
+ left_path: left_path,
13
+ right_path: right_path,
14
+ col_sep: col_sep,
15
+ headers_present: headers_present
16
+ }
17
+ :session
18
+ end
19
+ end
20
+
21
+ def test_builds_session_from_context
22
+ builder = FakeBuilder.new
23
+ step = Csvtool::Interface::CLI::Workflows::Steps::Parity::BuildSessionStep.new
24
+ context = {
25
+ session_builder: builder,
26
+ left_path: "/tmp/left.csv",
27
+ right_path: "/tmp/right.csv",
28
+ col_sep: "\t",
29
+ headers_present: false
30
+ }
31
+
32
+ result = step.call(context)
33
+
34
+ assert_nil result
35
+ assert_equal :session, context[:session]
36
+ assert_equal "/tmp/left.csv", builder.args[:left_path]
37
+ assert_equal "/tmp/right.csv", builder.args[:right_path]
38
+ assert_equal "\t", builder.args[:col_sep]
39
+ assert_equal false, builder.args[:headers_present]
40
+ end
41
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/steps/parity/collect_inputs_step"
5
+
6
+ class ParityCollectInputsStepTest < Minitest::Test
7
+ def test_collects_inputs_into_context
8
+ file_prompt = Object.new
9
+ separator_prompt = Object.new
10
+ headers_prompt = Object.new
11
+ def file_prompt.call(label:) = label.include?("Left") ? "/tmp/left.csv" : "/tmp/right.csv"
12
+ def separator_prompt.call = ","
13
+ def headers_prompt.call = true
14
+
15
+ step = Csvtool::Interface::CLI::Workflows::Steps::Parity::CollectInputsStep.new(
16
+ file_path_prompt: file_prompt,
17
+ separator_prompt: separator_prompt,
18
+ headers_present_prompt: headers_prompt
19
+ )
20
+ context = {}
21
+
22
+ result = step.call(context)
23
+
24
+ assert_nil result
25
+ assert_equal "/tmp/left.csv", context[:left_path]
26
+ assert_equal "/tmp/right.csv", context[:right_path]
27
+ assert_equal ",", context[:col_sep]
28
+ assert_equal true, context[:headers_present]
29
+ end
30
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/steps/parity/execute_step"
5
+
6
+ class ParityExecuteStepTest < Minitest::Test
7
+ Result = Struct.new(:ok, :data) do
8
+ def ok? = ok
9
+ end
10
+
11
+ class FakeUseCase
12
+ attr_reader :session
13
+
14
+ def call(session:)
15
+ @session = session
16
+ Result.new(true, { match: true })
17
+ end
18
+ end
19
+
20
+ class FakePresenter
21
+ attr_reader :data
22
+
23
+ def print_summary(data)
24
+ @data = data
25
+ end
26
+ end
27
+
28
+ def test_calls_use_case_and_presenter
29
+ step = Csvtool::Interface::CLI::Workflows::Steps::Parity::ExecuteStep.new
30
+ use_case = FakeUseCase.new
31
+ presenter = FakePresenter.new
32
+ context = { use_case: use_case, session: :session, presenter: presenter, handle_error: ->(_r) {} }
33
+
34
+ result = step.call(context)
35
+
36
+ assert_nil result
37
+ assert_equal :session, use_case.session
38
+ assert_equal true, presenter.data[:match]
39
+ end
40
+ end
@@ -0,0 +1,4 @@
1
+ id,name
2
+ 1,Alice
3
+ 1,Alice
4
+ 2,Bob
@@ -0,0 +1,3 @@
1
+ id,name
2
+ 1,Alice
3
+ 2,Bob
@@ -0,0 +1,4 @@
1
+ full_name,city
2
+ Alice,London
3
+ Bob,Paris
4
+ Cara,Berlin
@@ -0,0 +1,13 @@
1
+ name
2
+ Name12
3
+ Name11
4
+ Name10
5
+ Name09
6
+ Name08
7
+ Name07
8
+ Name06
9
+ Name05
10
+ Name04
11
+ Name03
12
+ Name02
13
+ Name01
@@ -0,0 +1,4 @@
1
+ name,city
2
+ Alice,London
3
+ Bob,Paris
4
+ Dina,Rome
@@ -0,0 +1,4 @@
1
+ name,city
2
+ Cara,Berlin
3
+ Alice,London
4
+ Bob,Paris
@@ -0,0 +1,4 @@
1
+ name city
2
+ Cara Berlin
3
+ Alice London
4
+ Bob Paris