csvops 0.4.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +60 -12
  3. data/docs/architecture.md +208 -21
  4. data/docs/release-v0.5.0-alpha.md +89 -0
  5. data/docs/release-v0.6.0-alpha.md +84 -0
  6. data/lib/csvtool/application/use_cases/run_cross_csv_dedupe.rb +17 -14
  7. data/lib/csvtool/application/use_cases/run_csv_parity.rb +70 -0
  8. data/lib/csvtool/application/use_cases/run_extraction.rb +63 -88
  9. data/lib/csvtool/application/use_cases/run_row_extraction.rb +45 -73
  10. data/lib/csvtool/application/use_cases/run_row_randomization.rb +56 -73
  11. data/lib/csvtool/cli.rb +11 -7
  12. data/lib/csvtool/domain/csv_parity_session/parity_options.rb +22 -0
  13. data/lib/csvtool/domain/csv_parity_session/parity_session.rb +20 -0
  14. data/lib/csvtool/domain/csv_parity_session/source_pair.rb +19 -0
  15. data/lib/csvtool/infrastructure/csv/csv_parity_comparator.rb +71 -0
  16. data/lib/csvtool/infrastructure/output/csv_cross_csv_dedupe_file_writer.rb +23 -0
  17. data/lib/csvtool/infrastructure/output/csv_file_writer.rb +1 -7
  18. data/lib/csvtool/infrastructure/output/csv_randomized_row_file_writer.rb +23 -0
  19. data/lib/csvtool/infrastructure/output/csv_row_file_writer.rb +2 -9
  20. data/lib/csvtool/interface/cli/errors/presenter.rb +4 -0
  21. data/lib/csvtool/interface/cli/menu_loop.rb +5 -2
  22. data/lib/csvtool/interface/cli/prompts/dedupe_key_selector_prompt.rb +30 -0
  23. data/lib/csvtool/interface/cli/prompts/file_path_prompt.rb +4 -2
  24. data/lib/csvtool/interface/cli/prompts/headers_present_prompt.rb +4 -2
  25. data/lib/csvtool/interface/cli/prompts/separator_prompt.rb +4 -2
  26. data/lib/csvtool/interface/cli/prompts/yes_no_prompt.rb +26 -0
  27. data/lib/csvtool/interface/cli/workflows/builders/column_session_builder.rb +32 -0
  28. data/lib/csvtool/interface/cli/workflows/builders/cross_csv_dedupe_session_builder.rb +35 -0
  29. data/lib/csvtool/interface/cli/workflows/builders/csv_parity_session_builder.rb +33 -0
  30. data/lib/csvtool/interface/cli/workflows/builders/row_extraction_session_builder.rb +22 -0
  31. data/lib/csvtool/interface/cli/workflows/builders/row_randomization_session_builder.rb +28 -0
  32. data/lib/csvtool/interface/cli/workflows/presenters/column_extraction_presenter.rb +25 -0
  33. data/lib/csvtool/interface/cli/workflows/presenters/cross_csv_dedupe_presenter.rb +39 -0
  34. data/lib/csvtool/interface/cli/workflows/presenters/csv_parity_presenter.rb +38 -0
  35. data/lib/csvtool/interface/cli/workflows/presenters/row_extraction_presenter.rb +34 -0
  36. data/lib/csvtool/interface/cli/workflows/presenters/row_randomization_presenter.rb +34 -0
  37. data/lib/csvtool/interface/cli/workflows/run_cross_csv_dedupe_workflow.rb +48 -125
  38. data/lib/csvtool/interface/cli/workflows/run_csv_parity_workflow.rb +66 -0
  39. data/lib/csvtool/interface/cli/workflows/run_extraction_workflow.rb +88 -0
  40. data/lib/csvtool/interface/cli/workflows/run_row_extraction_workflow.rb +86 -0
  41. data/lib/csvtool/interface/cli/workflows/run_row_randomization_workflow.rb +80 -0
  42. data/lib/csvtool/interface/cli/workflows/steps/cross_csv_dedupe/collect_options_step.rb +55 -0
  43. data/lib/csvtool/interface/cli/workflows/steps/cross_csv_dedupe/collect_profiles_step.rb +52 -0
  44. data/lib/csvtool/interface/cli/workflows/steps/cross_csv_dedupe/execute_step.rb +34 -0
  45. data/lib/csvtool/interface/cli/workflows/steps/extraction/build_preview_step.rb +40 -0
  46. data/lib/csvtool/interface/cli/workflows/steps/extraction/collect_destination_step.rb +28 -0
  47. data/lib/csvtool/interface/cli/workflows/steps/extraction/collect_inputs_step.rb +47 -0
  48. data/lib/csvtool/interface/cli/workflows/steps/extraction/execute_step.rb +32 -0
  49. data/lib/csvtool/interface/cli/workflows/steps/parity/build_session_step.rb +25 -0
  50. data/lib/csvtool/interface/cli/workflows/steps/parity/collect_inputs_step.rb +32 -0
  51. data/lib/csvtool/interface/cli/workflows/steps/parity/execute_step.rb +26 -0
  52. data/lib/csvtool/interface/cli/workflows/steps/row_extraction/collect_destination_step.rb +33 -0
  53. data/lib/csvtool/interface/cli/workflows/steps/row_extraction/collect_range_step.rb +35 -0
  54. data/lib/csvtool/interface/cli/workflows/steps/row_extraction/collect_source_step.rb +32 -0
  55. data/lib/csvtool/interface/cli/workflows/steps/row_extraction/execute_step.rb +43 -0
  56. data/lib/csvtool/interface/cli/workflows/steps/row_extraction/read_headers_step.rb +29 -0
  57. data/lib/csvtool/interface/cli/workflows/steps/row_randomization/collect_destination_step.rb +34 -0
  58. data/lib/csvtool/interface/cli/workflows/steps/row_randomization/collect_inputs_step.rb +49 -0
  59. data/lib/csvtool/interface/cli/workflows/steps/row_randomization/execute_step.rb +37 -0
  60. data/lib/csvtool/interface/cli/workflows/steps/workflow_step_pipeline.rb +25 -0
  61. data/lib/csvtool/interface/cli/workflows/support/output_destination_mapper.rb +23 -0
  62. data/lib/csvtool/interface/cli/workflows/support/result_error_handler.rb +22 -0
  63. data/lib/csvtool/version.rb +1 -1
  64. data/test/csvtool/application/use_cases/io_boundary_test.rb +26 -0
  65. data/test/csvtool/application/use_cases/run_cross_csv_dedupe_test.rb +28 -0
  66. data/test/csvtool/application/use_cases/run_csv_parity_test.rb +160 -0
  67. data/test/csvtool/application/use_cases/run_extraction_test.rb +72 -16
  68. data/test/csvtool/application/use_cases/run_row_extraction_test.rb +82 -102
  69. data/test/csvtool/application/use_cases/run_row_randomization_test.rb +96 -86
  70. data/test/csvtool/cli_test.rb +175 -21
  71. data/test/csvtool/cli_unit_test.rb +4 -4
  72. data/test/csvtool/domain/csv_parity_session/parity_options_test.rb +17 -0
  73. data/test/csvtool/domain/csv_parity_session/parity_session_test.rb +18 -0
  74. data/test/csvtool/domain/csv_parity_session/source_pair_test.rb +11 -0
  75. data/test/csvtool/infrastructure/csv/csv_parity_comparator_test.rb +78 -0
  76. data/test/csvtool/infrastructure/output/csv_cross_csv_dedupe_file_writer_test.rb +32 -0
  77. data/test/csvtool/infrastructure/output/csv_file_writer_test.rb +0 -4
  78. data/test/csvtool/infrastructure/output/csv_randomized_row_file_writer_test.rb +32 -0
  79. data/test/csvtool/infrastructure/output/csv_row_file_writer_test.rb +1 -4
  80. data/test/csvtool/interface/cli/errors/presenter_test.rb +2 -0
  81. data/test/csvtool/interface/cli/menu_loop_test.rb +59 -16
  82. data/test/csvtool/interface/cli/prompts/dedupe_key_selector_prompt_test.rb +30 -0
  83. data/test/csvtool/interface/cli/prompts/file_path_prompt_test.rb +9 -0
  84. data/test/csvtool/interface/cli/prompts/headers_present_prompt_test.rb +10 -0
  85. data/test/csvtool/interface/cli/prompts/separator_prompt_test.rb +10 -0
  86. data/test/csvtool/interface/cli/prompts/yes_no_prompt_test.rb +22 -0
  87. data/test/csvtool/interface/cli/workflows/builders/column_session_builder_test.rb +17 -0
  88. data/test/csvtool/interface/cli/workflows/builders/cross_csv_dedupe_session_builder_test.rb +36 -0
  89. data/test/csvtool/interface/cli/workflows/builders/csv_parity_session_builder_test.rb +20 -0
  90. data/test/csvtool/interface/cli/workflows/builders/row_extraction_session_builder_test.rb +21 -0
  91. data/test/csvtool/interface/cli/workflows/builders/row_randomization_session_builder_test.rb +26 -0
  92. data/test/csvtool/interface/cli/workflows/presenters/column_extraction_presenter_test.rb +24 -0
  93. data/test/csvtool/interface/cli/workflows/presenters/cross_csv_dedupe_presenter_test.rb +30 -0
  94. data/test/csvtool/interface/cli/workflows/presenters/csv_parity_presenter_test.rb +43 -0
  95. data/test/csvtool/interface/cli/workflows/presenters/row_extraction_presenter_test.rb +33 -0
  96. data/test/csvtool/interface/cli/workflows/presenters/row_randomization_presenter_test.rb +33 -0
  97. data/test/csvtool/interface/cli/workflows/run_csv_parity_workflow_test.rb +94 -0
  98. data/test/csvtool/interface/cli/workflows/run_extraction_workflow_test.rb +56 -0
  99. data/test/csvtool/interface/cli/workflows/run_row_extraction_workflow_test.rb +83 -0
  100. data/test/csvtool/interface/cli/workflows/run_row_randomization_workflow_test.rb +69 -0
  101. data/test/csvtool/interface/cli/workflows/steps/cross_csv_dedupe/collect_options_step_test.rb +41 -0
  102. data/test/csvtool/interface/cli/workflows/steps/extraction/collect_inputs_step_test.rb +66 -0
  103. data/test/csvtool/interface/cli/workflows/steps/parity/build_session_step_test.rb +41 -0
  104. data/test/csvtool/interface/cli/workflows/steps/parity/collect_inputs_step_test.rb +30 -0
  105. data/test/csvtool/interface/cli/workflows/steps/parity/execute_step_test.rb +40 -0
  106. data/test/csvtool/interface/cli/workflows/steps/row_extraction/collect_source_step_test.rb +39 -0
  107. data/test/csvtool/interface/cli/workflows/steps/row_extraction/execute_step_test.rb +91 -0
  108. data/test/csvtool/interface/cli/workflows/steps/row_extraction/read_headers_step_test.rb +57 -0
  109. data/test/csvtool/interface/cli/workflows/steps/row_randomization/collect_inputs_step_test.rb +37 -0
  110. data/test/csvtool/interface/cli/workflows/steps/workflow_step_pipeline_test.rb +30 -0
  111. data/test/csvtool/interface/cli/workflows/support/output_destination_mapper_test.rb +23 -0
  112. data/test/csvtool/interface/cli/workflows/support/result_error_handler_test.rb +34 -0
  113. data/test/fixtures/parity_duplicates_left.csv +4 -0
  114. data/test/fixtures/parity_duplicates_right.csv +3 -0
  115. data/test/fixtures/parity_people_header_mismatch.csv +4 -0
  116. data/test/fixtures/parity_people_many_reordered.csv +13 -0
  117. data/test/fixtures/parity_people_mismatch.csv +4 -0
  118. data/test/fixtures/parity_people_reordered.csv +4 -0
  119. data/test/fixtures/parity_people_reordered.tsv +4 -0
  120. metadata +90 -1
@@ -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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/presenters/row_extraction_presenter"
5
+
6
+ class RowExtractionPresenterTest < Minitest::Test
7
+ def test_prints_header_once_then_rows
8
+ out = StringIO.new
9
+ presenter = Csvtool::Interface::CLI::Workflows::Presenters::RowExtractionPresenter.new(
10
+ stdout: out,
11
+ headers: ["name", "city"],
12
+ col_sep: ","
13
+ )
14
+
15
+ presenter.print_row(["Alice", "London"])
16
+ presenter.print_row(["Bob", "Paris"])
17
+
18
+ assert_equal "name,city\nAlice,London\nBob,Paris\n", out.string
19
+ end
20
+
21
+ def test_prints_file_written_message
22
+ out = StringIO.new
23
+ presenter = Csvtool::Interface::CLI::Workflows::Presenters::RowExtractionPresenter.new(
24
+ stdout: out,
25
+ headers: ["name"],
26
+ col_sep: ","
27
+ )
28
+
29
+ presenter.print_file_written("/tmp/out.csv")
30
+
31
+ assert_includes out.string, "Wrote output to /tmp/out.csv"
32
+ end
33
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/presenters/row_randomization_presenter"
5
+
6
+ class RowRandomizationPresenterTest < Minitest::Test
7
+ def test_prints_console_start_and_rows
8
+ out = StringIO.new
9
+ presenter = Csvtool::Interface::CLI::Workflows::Presenters::RowRandomizationPresenter.new(
10
+ stdout: out,
11
+ headers: ["name", "city"],
12
+ col_sep: ","
13
+ )
14
+
15
+ presenter.print_console_start
16
+ presenter.print_row(["Alice", "London"])
17
+
18
+ assert_equal "\nname,city\nAlice,London\n", out.string
19
+ end
20
+
21
+ def test_prints_file_written_message
22
+ out = StringIO.new
23
+ presenter = Csvtool::Interface::CLI::Workflows::Presenters::RowRandomizationPresenter.new(
24
+ stdout: out,
25
+ headers: nil,
26
+ col_sep: ","
27
+ )
28
+
29
+ presenter.print_file_written("/tmp/out.csv")
30
+
31
+ assert_includes out.string, "Wrote output to /tmp/out.csv"
32
+ end
33
+ 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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/run_extraction_workflow"
5
+ require "tmpdir"
6
+
7
+ class RunExtractionWorkflowTest < Minitest::Test
8
+ def fixture_path(name)
9
+ File.expand_path("../../../../fixtures/#{name}", __dir__)
10
+ end
11
+
12
+ def test_missing_file_path_reports_error
13
+ out = StringIO.new
14
+ workflow = Csvtool::Interface::CLI::Workflows::RunExtractionWorkflow.new(
15
+ stdin: StringIO.new("/tmp/not-present.csv\n\n"),
16
+ stdout: out
17
+ )
18
+
19
+ workflow.call
20
+
21
+ assert_includes out.string, "File not found: /tmp/not-present.csv"
22
+ end
23
+
24
+ def test_workflow_can_run_console_happy_path
25
+ out = StringIO.new
26
+ fixture = fixture_path("sample_people.csv")
27
+ input = ["#{fixture}", "1", "", "1", "", "y", ""].join("\n") + "\n"
28
+
29
+ Csvtool::Interface::CLI::Workflows::RunExtractionWorkflow.new(
30
+ stdin: StringIO.new(input),
31
+ stdout: out
32
+ ).call
33
+
34
+ assert_includes out.string, "Alice"
35
+ assert_includes out.string, "Bob"
36
+ assert_includes out.string, "Cara"
37
+ end
38
+
39
+ def test_workflow_can_write_output_file
40
+ out = StringIO.new
41
+
42
+ Dir.mktmpdir do |dir|
43
+ output_path = File.join(dir, "names.csv")
44
+ fixture = fixture_path("sample_people.csv")
45
+ input = ["#{fixture}", "1", "", "1", "", "y", "2", output_path].join("\n") + "\n"
46
+
47
+ Csvtool::Interface::CLI::Workflows::RunExtractionWorkflow.new(
48
+ stdin: StringIO.new(input),
49
+ stdout: out
50
+ ).call
51
+
52
+ assert_includes out.string, "Wrote output to #{output_path}"
53
+ assert_equal "name\nAlice\nBob\nCara\n", File.read(output_path)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/run_row_extraction_workflow"
5
+ require "tmpdir"
6
+
7
+ class RunRowExtractionWorkflowTest < Minitest::Test
8
+ def fixture_path(name)
9
+ File.expand_path("../../../../fixtures/#{name}", __dir__)
10
+ end
11
+
12
+ def test_missing_file_path_reports_error
13
+ out = StringIO.new
14
+ workflow = Csvtool::Interface::CLI::Workflows::RunRowExtractionWorkflow.new(
15
+ stdin: StringIO.new("/tmp/not-present.csv\n\n"),
16
+ stdout: out
17
+ )
18
+
19
+ workflow.call
20
+
21
+ assert_includes out.string, "File not found: /tmp/not-present.csv"
22
+ end
23
+
24
+ def test_workflow_can_run_console_happy_path
25
+ out = StringIO.new
26
+ fixture = fixture_path("sample_people.csv")
27
+ input = [fixture, "", "2", "3", ""].join("\n") + "\n"
28
+
29
+ Csvtool::Interface::CLI::Workflows::RunRowExtractionWorkflow.new(
30
+ stdin: StringIO.new(input),
31
+ stdout: out
32
+ ).call
33
+
34
+ assert_includes out.string, "name,city"
35
+ assert_includes out.string, "Bob,Paris"
36
+ assert_includes out.string, "Cara,Berlin"
37
+ refute_includes out.string, "Alice,London"
38
+ end
39
+
40
+ def test_workflow_can_write_output_file
41
+ out = StringIO.new
42
+
43
+ Dir.mktmpdir do |dir|
44
+ output_path = File.join(dir, "rows.csv")
45
+ fixture = fixture_path("sample_people.csv")
46
+ input = [fixture, "", "2", "3", "2", output_path].join("\n") + "\n"
47
+
48
+ Csvtool::Interface::CLI::Workflows::RunRowExtractionWorkflow.new(
49
+ stdin: StringIO.new(input),
50
+ stdout: out
51
+ ).call
52
+
53
+ assert_includes out.string, "Wrote output to #{output_path}"
54
+ assert_equal "name,city\nBob,Paris\nCara,Berlin\n", File.read(output_path)
55
+ end
56
+ end
57
+
58
+ def test_rejects_non_numeric_start_row
59
+ out = StringIO.new
60
+ fixture = fixture_path("sample_people.csv")
61
+ input = [fixture, "", "abc", "3", ""].join("\n") + "\n"
62
+
63
+ Csvtool::Interface::CLI::Workflows::RunRowExtractionWorkflow.new(
64
+ stdin: StringIO.new(input),
65
+ stdout: out
66
+ ).call
67
+
68
+ assert_includes out.string, "Start row must be a positive integer."
69
+ end
70
+
71
+ def test_reports_out_of_bounds_range
72
+ out = StringIO.new
73
+ fixture = fixture_path("sample_people.csv")
74
+ input = [fixture, "", "10", "12", ""].join("\n") + "\n"
75
+
76
+ Csvtool::Interface::CLI::Workflows::RunRowExtractionWorkflow.new(
77
+ stdin: StringIO.new(input),
78
+ stdout: out
79
+ ).call
80
+
81
+ assert_includes out.string, "Row range is out of bounds. File has 3 data rows."
82
+ end
83
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/run_row_randomization_workflow"
5
+ require "tmpdir"
6
+
7
+ class RunRowRandomizationWorkflowTest < Minitest::Test
8
+ def fixture_path(name)
9
+ File.expand_path("../../../../fixtures/#{name}", __dir__)
10
+ end
11
+
12
+ def test_missing_file_shows_friendly_error
13
+ output = StringIO.new
14
+ input = StringIO.new("/tmp/does-not-exist.csv\n\n")
15
+
16
+ Csvtool::Interface::CLI::Workflows::RunRowRandomizationWorkflow.new(stdin: input, stdout: output).call
17
+
18
+ assert_includes output.string, "File not found: /tmp/does-not-exist.csv"
19
+ end
20
+
21
+ def test_workflow_prints_header_then_all_randomized_rows
22
+ output = StringIO.new
23
+ input = StringIO.new([fixture_path("sample_people.csv"), "", "", "", ""].join("\n") + "\n")
24
+
25
+ Csvtool::Interface::CLI::Workflows::RunRowRandomizationWorkflow.new(stdin: input, stdout: output).call
26
+
27
+ assert_includes output.string, "name,city"
28
+ assert_includes output.string, "Alice,London"
29
+ assert_includes output.string, "Bob,Paris"
30
+ assert_includes output.string, "Cara,Berlin"
31
+ end
32
+
33
+ def test_workflow_can_write_randomized_rows_to_file
34
+ output = StringIO.new
35
+
36
+ Dir.mktmpdir do |dir|
37
+ output_path = File.join(dir, "randomized.csv")
38
+ input = StringIO.new([fixture_path("sample_people.csv"), "", "", "", "2", output_path].join("\n") + "\n")
39
+
40
+ Csvtool::Interface::CLI::Workflows::RunRowRandomizationWorkflow.new(stdin: input, stdout: output).call
41
+
42
+ written = File.read(output_path).lines.map(&:strip)
43
+ assert_equal "name,city", written.first
44
+ assert_equal ["Alice,London", "Bob,Paris", "Cara,Berlin"].sort, written[1..].sort
45
+ assert_includes output.string, "Wrote output to #{output_path}"
46
+ end
47
+ end
48
+
49
+ def test_workflow_supports_headerless_mode
50
+ output = StringIO.new
51
+ input = StringIO.new([fixture_path("sample_people_no_headers.csv"), "", "n", "", ""].join("\n") + "\n")
52
+
53
+ Csvtool::Interface::CLI::Workflows::RunRowRandomizationWorkflow.new(stdin: input, stdout: output).call
54
+
55
+ refute_includes output.string, "name,city"
56
+ assert_includes output.string, "Alice,London"
57
+ assert_includes output.string, "Bob,Paris"
58
+ assert_includes output.string, "Cara,Berlin"
59
+ end
60
+
61
+ def test_invalid_seed_shows_friendly_error
62
+ output = StringIO.new
63
+ input = StringIO.new([fixture_path("sample_people.csv"), "", "", "abc"].join("\n") + "\n")
64
+
65
+ Csvtool::Interface::CLI::Workflows::RunRowRandomizationWorkflow.new(stdin: input, stdout: output).call
66
+
67
+ assert_includes output.string, "Seed must be an integer."
68
+ end
69
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/steps/cross_csv_dedupe/collect_options_step"
5
+ require "csvtool/domain/cross_csv_dedupe_session/csv_profile"
6
+
7
+ class CrossCsvDedupeCollectOptionsStepTest < Minitest::Test
8
+ class FakeErrors
9
+ attr_reader :column_not_found_called
10
+
11
+ def column_not_found
12
+ @column_not_found_called = true
13
+ end
14
+ end
15
+
16
+ def test_halts_when_source_selector_invalid
17
+ selector_prompt = Object.new
18
+ yes_no_prompt = Object.new
19
+ output_destination_prompt = Object.new
20
+ session_builder = Object.new
21
+ mapper = Object.new
22
+ errors = FakeErrors.new
23
+
24
+ def selector_prompt.call(label:, headers_present:) = nil
25
+
26
+ step = Csvtool::Interface::CLI::Workflows::Steps::CrossCsvDedupe::CollectOptionsStep.new(
27
+ selector_prompt: selector_prompt,
28
+ yes_no_prompt: yes_no_prompt,
29
+ output_destination_prompt: output_destination_prompt,
30
+ errors: errors
31
+ )
32
+
33
+ source = Csvtool::Domain::CrossCsvDedupeSession::CsvProfile.new(path: "/tmp/a.csv", separator: ",", headers_present: true)
34
+ reference = Csvtool::Domain::CrossCsvDedupeSession::CsvProfile.new(path: "/tmp/b.csv", separator: ",", headers_present: true)
35
+
36
+ result = step.call(source: source, reference: reference, session_builder: session_builder, output_destination_mapper: mapper)
37
+
38
+ assert_equal :halt, result
39
+ assert_equal true, errors.column_not_found_called
40
+ end
41
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/steps/extraction/collect_inputs_step"
5
+
6
+ class ExtractionCollectInputsStepTest < Minitest::Test
7
+ Result = Struct.new(:ok, :data) do
8
+ def ok? = ok
9
+ end
10
+
11
+ class FakeUseCase
12
+ def initialize(result)
13
+ @result = result
14
+ end
15
+
16
+ def read_headers(file_path:, col_sep:)
17
+ @result
18
+ end
19
+ end
20
+
21
+ def test_halts_when_separator_missing
22
+ file_prompt = Object.new
23
+ separator_prompt = Object.new
24
+ selector_prompt = Object.new
25
+ skip_prompt = Object.new
26
+ def file_prompt.call = "/tmp/data.csv"
27
+ def separator_prompt.call = nil
28
+
29
+ step = Csvtool::Interface::CLI::Workflows::Steps::Extraction::CollectInputsStep.new(
30
+ file_path_prompt: file_prompt,
31
+ separator_prompt: separator_prompt,
32
+ column_selector_prompt: selector_prompt,
33
+ skip_blanks_prompt: skip_prompt
34
+ )
35
+
36
+ assert_equal :halt, step.call(
37
+ use_case: FakeUseCase.new(Result.new(true, { headers: [] })),
38
+ session_builder: Object.new,
39
+ handle_error: ->(_r) {}
40
+ )
41
+ end
42
+
43
+ def test_halts_when_header_read_fails
44
+ file_prompt = Object.new
45
+ separator_prompt = Object.new
46
+ selector_prompt = Object.new
47
+ skip_prompt = Object.new
48
+ builder = Object.new
49
+ handled = []
50
+ def file_prompt.call = "/tmp/data.csv"
51
+ def separator_prompt.call = ","
52
+
53
+ step = Csvtool::Interface::CLI::Workflows::Steps::Extraction::CollectInputsStep.new(
54
+ file_path_prompt: file_prompt,
55
+ separator_prompt: separator_prompt,
56
+ column_selector_prompt: selector_prompt,
57
+ skip_blanks_prompt: skip_prompt
58
+ )
59
+
60
+ fail_result = Result.new(false, {})
61
+ result = step.call(use_case: FakeUseCase.new(fail_result), session_builder: builder, handle_error: ->(r) { handled << r })
62
+
63
+ assert_equal :halt, result
64
+ assert_equal [fail_result], handled
65
+ end
66
+ 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,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/steps/row_extraction/collect_source_step"
5
+
6
+ class CollectSourceStepTest < Minitest::Test
7
+ def test_collects_file_and_separator
8
+ file_prompt = Object.new
9
+ separator_prompt = Object.new
10
+ def file_prompt.call = "/tmp/data.csv"
11
+ def separator_prompt.call = ","
12
+
13
+ step = Csvtool::Interface::CLI::Workflows::Steps::RowExtraction::CollectSourceStep.new(
14
+ file_path_prompt: file_prompt,
15
+ separator_prompt: separator_prompt
16
+ )
17
+ context = {}
18
+
19
+ result = step.call(context)
20
+
21
+ assert_nil result
22
+ assert_equal "/tmp/data.csv", context[:file_path]
23
+ assert_equal ",", context[:col_sep]
24
+ end
25
+
26
+ def test_halts_when_separator_missing
27
+ file_prompt = Object.new
28
+ separator_prompt = Object.new
29
+ def file_prompt.call = "/tmp/data.csv"
30
+ def separator_prompt.call = nil
31
+
32
+ step = Csvtool::Interface::CLI::Workflows::Steps::RowExtraction::CollectSourceStep.new(
33
+ file_path_prompt: file_prompt,
34
+ separator_prompt: separator_prompt
35
+ )
36
+
37
+ assert_equal :halt, step.call({})
38
+ end
39
+ end