csvops 0.7.0.alpha → 0.9.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +80 -20
  3. data/docs/architecture.md +67 -4
  4. data/docs/cli-output-conventions.md +49 -0
  5. data/docs/release-v0.8.0-alpha.md +88 -0
  6. data/docs/release-v0.9.0-alpha.md +80 -0
  7. data/lib/csvtool/application/use_cases/run_csv_stats.rb +64 -0
  8. data/lib/csvtool/cli.rb +136 -12
  9. data/lib/csvtool/domain/csv_stats_session/stats_options.rb +11 -0
  10. data/lib/csvtool/domain/csv_stats_session/stats_session.rb +25 -0
  11. data/lib/csvtool/domain/csv_stats_session/stats_source.rb +17 -0
  12. data/lib/csvtool/infrastructure/csv/csv_stats_scanner.rb +67 -0
  13. data/lib/csvtool/infrastructure/output/csv_stats_file_writer.rb +26 -0
  14. data/lib/csvtool/interface/cli/menu_loop.rb +9 -5
  15. data/lib/csvtool/interface/cli/output/color_policy.rb +25 -0
  16. data/lib/csvtool/interface/cli/output/colorizer.rb +27 -0
  17. data/lib/csvtool/interface/cli/output/formatters/csv_row_formatter.rb +19 -0
  18. data/lib/csvtool/interface/cli/output/formatters/stats_formatter.rb +57 -0
  19. data/lib/csvtool/interface/cli/output/streams.rb +22 -0
  20. data/lib/csvtool/interface/cli/output/table_renderer.rb +70 -0
  21. data/lib/csvtool/interface/cli/workflows/builders/csv_stats_session_builder.rb +28 -0
  22. data/lib/csvtool/interface/cli/workflows/presenters/cross_csv_dedupe_presenter.rb +17 -5
  23. data/lib/csvtool/interface/cli/workflows/presenters/csv_parity_presenter.rb +15 -4
  24. data/lib/csvtool/interface/cli/workflows/presenters/csv_split_presenter.rb +15 -6
  25. data/lib/csvtool/interface/cli/workflows/presenters/csv_stats_presenter.rb +43 -0
  26. data/lib/csvtool/interface/cli/workflows/presenters/row_extraction_presenter.rb +5 -4
  27. data/lib/csvtool/interface/cli/workflows/presenters/row_randomization_presenter.rb +5 -4
  28. data/lib/csvtool/interface/cli/workflows/run_cross_csv_dedupe_workflow.rb +9 -8
  29. data/lib/csvtool/interface/cli/workflows/run_csv_parity_workflow.rb +6 -5
  30. data/lib/csvtool/interface/cli/workflows/run_csv_split_workflow.rb +11 -10
  31. data/lib/csvtool/interface/cli/workflows/run_csv_stats_workflow.rb +78 -0
  32. data/lib/csvtool/interface/cli/workflows/run_extraction_workflow.rb +9 -8
  33. data/lib/csvtool/interface/cli/workflows/run_row_extraction_workflow.rb +7 -6
  34. data/lib/csvtool/interface/cli/workflows/run_row_randomization_workflow.rb +8 -7
  35. data/lib/csvtool/interface/cli/workflows/steps/csv_stats/build_session_step.rb +25 -0
  36. data/lib/csvtool/interface/cli/workflows/steps/csv_stats/collect_destination_step.rb +27 -0
  37. data/lib/csvtool/interface/cli/workflows/steps/csv_stats/collect_inputs_step.rb +31 -0
  38. data/lib/csvtool/interface/cli/workflows/steps/csv_stats/execute_step.rb +27 -0
  39. data/lib/csvtool/version.rb +1 -1
  40. data/test/csvtool/application/use_cases/run_csv_stats_test.rb +165 -0
  41. data/test/csvtool/cli_test.rb +376 -68
  42. data/test/csvtool/cli_unit_test.rb +5 -5
  43. data/test/csvtool/infrastructure/csv/csv_stats_scanner_test.rb +68 -0
  44. data/test/csvtool/infrastructure/output/csv_stats_file_writer_test.rb +38 -0
  45. data/test/csvtool/interface/cli/menu_loop_test.rb +34 -11
  46. data/test/csvtool/interface/cli/output/color_policy_test.rb +40 -0
  47. data/test/csvtool/interface/cli/output/colorizer_test.rb +28 -0
  48. data/test/csvtool/interface/cli/output/formatters/csv_row_formatter_test.rb +22 -0
  49. data/test/csvtool/interface/cli/output/formatters/stats_formatter_test.rb +51 -0
  50. data/test/csvtool/interface/cli/output/streams_test.rb +25 -0
  51. data/test/csvtool/interface/cli/output/table_renderer_test.rb +36 -0
  52. data/test/csvtool/interface/cli/workflows/builders/csv_stats_session_builder_test.rb +19 -0
  53. data/test/csvtool/interface/cli/workflows/presenters/cross_csv_dedupe_presenter_test.rb +4 -1
  54. data/test/csvtool/interface/cli/workflows/presenters/csv_parity_presenter_test.rb +5 -1
  55. data/test/csvtool/interface/cli/workflows/presenters/csv_split_presenter_test.rb +22 -4
  56. data/test/csvtool/interface/cli/workflows/presenters/csv_stats_presenter_test.rb +39 -0
  57. data/test/csvtool/interface/cli/workflows/run_cross_csv_dedupe_workflow_test.rb +10 -7
  58. data/test/csvtool/interface/cli/workflows/run_csv_parity_workflow_test.rb +3 -1
  59. data/test/csvtool/interface/cli/workflows/run_csv_split_workflow_test.rb +5 -3
  60. data/test/csvtool/interface/cli/workflows/run_csv_stats_workflow_test.rb +151 -0
  61. data/test/csvtool/interface/cli/workflows/steps/csv_stats/build_session_step_test.rb +36 -0
  62. data/test/csvtool/interface/cli/workflows/steps/csv_stats/collect_destination_step_test.rb +49 -0
  63. data/test/csvtool/interface/cli/workflows/steps/csv_stats/collect_inputs_step_test.rb +61 -0
  64. data/test/csvtool/interface/cli/workflows/steps/csv_stats/execute_step_test.rb +65 -0
  65. metadata +39 -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("6\n"), stdout: StringIO.new, stderr: StringIO.new)
19
+ status = Csvtool::CLI.start(["menu"], stdin: StringIO.new("8\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", "", "6"].join("\n") + "\n"
31
+ input = ["2", fixture, "", "2", "3", "", "8"].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, "", "", "", "", "6"].join("\n") + "\n"
42
+ input = ["3", fixture, "", "", "", "", "8"].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,12 +52,12 @@ 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", "", "", "", "6"].join("\n") + "\n"
55
+ input = ["4", source_fixture, "", "", reference_fixture, "", "", "customer_id", "external_id", "", "", "", "8"].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"
59
59
  assert_includes stdout.string, "1,Alice"
60
60
  assert_includes stdout.string, "3,Cara"
61
- assert_includes stdout.string, "Summary: source_rows=5 removed_rows=3 kept_rows=2"
61
+ assert_includes stdout.string, "Summary"
62
62
  end
63
63
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../test_helper"
4
+ require "csv"
5
+ require "csvtool/infrastructure/csv/csv_stats_scanner"
6
+ require "tmpdir"
7
+
8
+ class CsvStatsScannerTest < Minitest::Test
9
+ def fixture_path(name)
10
+ File.expand_path("../../../fixtures/#{name}", __dir__)
11
+ end
12
+
13
+ def test_scans_headers_mode_with_streaming_foreach
14
+ source = fixture_path("sample_people_blanks.csv")
15
+ csv = Object.new
16
+ received = nil
17
+
18
+ define_singleton_foreach(csv) do |path, headers:, col_sep:, &block|
19
+ received = { path: path, headers: headers, col_sep: col_sep }
20
+ ::CSV.foreach(path, headers: headers, col_sep: col_sep, &block)
21
+ end
22
+
23
+ result = Csvtool::Infrastructure::CSV::CsvStatsScanner.new(csv: csv).call(
24
+ file_path: source,
25
+ col_sep: ",",
26
+ headers_present: true
27
+ )
28
+
29
+ assert_equal({ path: source, headers: true, col_sep: "," }, received)
30
+ assert_equal 5, result[:row_count]
31
+ assert_equal 2, result[:column_count]
32
+ assert_equal ["name", "city"], result[:headers]
33
+ assert_equal [
34
+ { name: "name", blank_count: 2, non_blank_count: 3 },
35
+ { name: "city", blank_count: 1, non_blank_count: 4 }
36
+ ], result[:column_stats]
37
+ end
38
+
39
+ def test_scans_large_file_in_single_pass_shape
40
+ Dir.mktmpdir do |dir|
41
+ path = File.join(dir, "large.csv")
42
+ File.open(path, "w") do |f|
43
+ f.puts("id,value")
44
+ 20_000.times { |i| f.puts("#{i},v#{i}") }
45
+ end
46
+
47
+ result = Csvtool::Infrastructure::CSV::CsvStatsScanner.new.call(
48
+ file_path: path,
49
+ col_sep: ",",
50
+ headers_present: true
51
+ )
52
+
53
+ assert_equal 20_000, result[:row_count]
54
+ assert_equal 2, result[:column_count]
55
+ assert_equal ["id", "value"], result[:headers]
56
+ assert_equal [
57
+ { name: "id", blank_count: 0, non_blank_count: 20_000 },
58
+ { name: "value", blank_count: 0, non_blank_count: 20_000 }
59
+ ], result[:column_stats]
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def define_singleton_foreach(obj, &implementation)
66
+ obj.define_singleton_method(:foreach, &implementation)
67
+ end
68
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../test_helper"
4
+ require "csvtool/infrastructure/output/csv_stats_file_writer"
5
+ require "tmpdir"
6
+
7
+ class InfrastructureCsvStatsFileWriterTest < Minitest::Test
8
+ def test_writes_stats_as_metric_value_csv
9
+ writer = Csvtool::Infrastructure::Output::CsvStatsFileWriter.new
10
+
11
+ Dir.mktmpdir do |dir|
12
+ path = File.join(dir, "stats.csv")
13
+ writer.call(
14
+ path: path,
15
+ data: {
16
+ row_count: 3,
17
+ column_count: 2,
18
+ headers: ["name", "city"],
19
+ column_stats: [
20
+ { name: "name", non_blank_count: 3, blank_count: 0 },
21
+ { name: "city", non_blank_count: 2, blank_count: 1 }
22
+ ]
23
+ }
24
+ )
25
+
26
+ assert_equal [
27
+ "metric,value",
28
+ "row_count,3",
29
+ "column_count,2",
30
+ "headers,name|city",
31
+ "column.name.non_blank,3",
32
+ "column.name.blank,0",
33
+ "column.city.non_blank,2",
34
+ "column.city.blank,1"
35
+ ], File.read(path).lines.map(&:strip)
36
+ end
37
+ end
38
+ end
@@ -17,7 +17,7 @@ class MenuLoopTest < Minitest::Test
17
17
  end
18
18
 
19
19
  def test_routes_extract_column_then_exit
20
- menu, actions, = build_menu("1\n7\n")
20
+ menu, actions, = build_menu("1\n8\n")
21
21
  status = menu.run
22
22
 
23
23
  assert_equal 0, status
@@ -27,10 +27,11 @@ class MenuLoopTest < Minitest::Test
27
27
  assert_equal 0, actions[:dedupe].runs
28
28
  assert_equal 0, actions[:parity].runs
29
29
  assert_equal 0, actions[:split].runs
30
+ assert_equal 0, actions[:stats].runs
30
31
  end
31
32
 
32
33
  def test_routes_extract_rows_then_exit
33
- menu, actions, = build_menu("2\n7\n")
34
+ menu, actions, = build_menu("2\n8\n")
34
35
  status = menu.run
35
36
 
36
37
  assert_equal 0, status
@@ -40,10 +41,11 @@ class MenuLoopTest < Minitest::Test
40
41
  assert_equal 0, actions[:dedupe].runs
41
42
  assert_equal 0, actions[:parity].runs
42
43
  assert_equal 0, actions[:split].runs
44
+ assert_equal 0, actions[:stats].runs
43
45
  end
44
46
 
45
47
  def test_routes_randomize_rows_then_exit
46
- menu, actions, = build_menu("3\n7\n")
48
+ menu, actions, = build_menu("3\n8\n")
47
49
  status = menu.run
48
50
 
49
51
  assert_equal 0, status
@@ -53,10 +55,11 @@ class MenuLoopTest < Minitest::Test
53
55
  assert_equal 0, actions[:dedupe].runs
54
56
  assert_equal 0, actions[:parity].runs
55
57
  assert_equal 0, actions[:split].runs
58
+ assert_equal 0, actions[:stats].runs
56
59
  end
57
60
 
58
61
  def test_routes_dedupe_then_exit
59
- menu, actions, = build_menu("4\n7\n")
62
+ menu, actions, = build_menu("4\n8\n")
60
63
  status = menu.run
61
64
 
62
65
  assert_equal 0, status
@@ -66,10 +69,11 @@ class MenuLoopTest < Minitest::Test
66
69
  assert_equal 1, actions[:dedupe].runs
67
70
  assert_equal 0, actions[:parity].runs
68
71
  assert_equal 0, actions[:split].runs
72
+ assert_equal 0, actions[:stats].runs
69
73
  end
70
74
 
71
75
  def test_routes_parity_then_exit
72
- menu, actions, = build_menu("5\n7\n")
76
+ menu, actions, = build_menu("5\n8\n")
73
77
  status = menu.run
74
78
 
75
79
  assert_equal 0, status
@@ -79,10 +83,11 @@ class MenuLoopTest < Minitest::Test
79
83
  assert_equal 0, actions[:dedupe].runs
80
84
  assert_equal 1, actions[:parity].runs
81
85
  assert_equal 0, actions[:split].runs
86
+ assert_equal 0, actions[:stats].runs
82
87
  end
83
88
 
84
89
  def test_routes_split_then_exit
85
- menu, actions, stdout = build_menu("6\n7\n")
90
+ menu, actions, stdout = build_menu("6\n8\n")
86
91
  status = menu.run
87
92
 
88
93
  assert_equal 0, status
@@ -92,20 +97,36 @@ class MenuLoopTest < Minitest::Test
92
97
  assert_equal 0, actions[:dedupe].runs
93
98
  assert_equal 0, actions[:parity].runs
94
99
  assert_equal 1, actions[:split].runs
100
+ assert_equal 0, actions[:stats].runs
95
101
  assert_includes stdout.string, "CSV Tool Menu"
96
102
  end
97
103
 
104
+ def test_routes_stats_then_exit
105
+ menu, actions, = build_menu("7\n8\n")
106
+ status = menu.run
107
+
108
+ assert_equal 0, status
109
+ assert_equal 0, actions[:column].runs
110
+ assert_equal 0, actions[:rows].runs
111
+ assert_equal 0, actions[:randomize].runs
112
+ assert_equal 0, actions[:dedupe].runs
113
+ assert_equal 0, actions[:parity].runs
114
+ assert_equal 0, actions[:split].runs
115
+ assert_equal 1, actions[:stats].runs
116
+ end
117
+
98
118
  def test_invalid_choice_shows_prompt
99
- menu, actions, stdout = build_menu("x\n7\n")
119
+ menu, actions, stdout = build_menu("x\n8\n")
100
120
  menu.run
101
121
 
102
- assert_includes stdout.string, "Please choose 1, 2, 3, 4, 5, 6, or 7."
122
+ assert_includes stdout.string, "Please choose 1, 2, 3, 4, 5, 6, 7, or 8."
103
123
  assert_equal 0, actions[:column].runs
104
124
  assert_equal 0, actions[:rows].runs
105
125
  assert_equal 0, actions[:randomize].runs
106
126
  assert_equal 0, actions[:dedupe].runs
107
127
  assert_equal 0, actions[:parity].runs
108
128
  assert_equal 0, actions[:split].runs
129
+ assert_equal 0, actions[:stats].runs
109
130
  end
110
131
 
111
132
  private
@@ -117,20 +138,22 @@ class MenuLoopTest < Minitest::Test
117
138
  randomize: FakeAction.new,
118
139
  dedupe: FakeAction.new,
119
140
  parity: FakeAction.new,
120
- split: FakeAction.new
141
+ split: FakeAction.new,
142
+ stats: FakeAction.new
121
143
  }
122
144
  stdout = StringIO.new
123
145
 
124
146
  menu = Csvtool::Interface::CLI::MenuLoop.new(
125
147
  stdin: StringIO.new(input),
126
148
  stdout: stdout,
127
- menu_options: ["Extract column", "Extract rows (range)", "Randomize rows", "Dedupe using another CSV", "Validate parity", "Split CSV into chunks", "Exit"],
149
+ menu_options: ["Extract column", "Extract rows (range)", "Randomize rows", "Dedupe using another CSV", "Validate parity", "Split CSV into chunks", "CSV stats summary", "Exit"],
128
150
  extract_column_action: actions[:column],
129
151
  extract_rows_action: actions[:rows],
130
152
  randomize_rows_action: actions[:randomize],
131
153
  dedupe_action: actions[:dedupe],
132
154
  parity_action: actions[:parity],
133
- split_action: actions[:split]
155
+ split_action: actions[:split],
156
+ stats_action: actions[:stats]
134
157
  )
135
158
 
136
159
  [menu, actions, stdout]
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../test_helper"
4
+ require "csvtool/interface/cli/output/color_policy"
5
+
6
+ class ColorPolicyTest < Minitest::Test
7
+ class TtyIO
8
+ def tty? = true
9
+ end
10
+
11
+ class NonTtyIO
12
+ def tty? = false
13
+ end
14
+
15
+ def test_auto_uses_tty
16
+ enabled = Csvtool::Interface::CLI::Output::ColorPolicy.new(mode: "auto", io: TtyIO.new, env: {}).enabled?
17
+ disabled = Csvtool::Interface::CLI::Output::ColorPolicy.new(mode: "auto", io: NonTtyIO.new, env: {}).enabled?
18
+
19
+ assert_equal true, enabled
20
+ assert_equal false, disabled
21
+ end
22
+
23
+ def test_never_disables_color
24
+ policy = Csvtool::Interface::CLI::Output::ColorPolicy.new(mode: "never", io: TtyIO.new, env: {})
25
+
26
+ assert_equal false, policy.enabled?
27
+ end
28
+
29
+ def test_always_enables_color_even_with_no_color
30
+ policy = Csvtool::Interface::CLI::Output::ColorPolicy.new(mode: "always", io: NonTtyIO.new, env: { "NO_COLOR" => "1" })
31
+
32
+ assert_equal true, policy.enabled?
33
+ end
34
+
35
+ def test_no_color_disables_auto
36
+ policy = Csvtool::Interface::CLI::Output::ColorPolicy.new(mode: "auto", io: TtyIO.new, env: { "NO_COLOR" => "1" })
37
+
38
+ assert_equal false, policy.enabled?
39
+ end
40
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../test_helper"
4
+ require "csvtool/interface/cli/output/colorizer"
5
+
6
+ class ColorizerTest < Minitest::Test
7
+ class FakePolicy
8
+ def initialize(enabled)
9
+ @enabled = enabled
10
+ end
11
+
12
+ def enabled?
13
+ @enabled
14
+ end
15
+ end
16
+
17
+ def test_wraps_text_when_enabled
18
+ colorizer = Csvtool::Interface::CLI::Output::Colorizer.new(policy: FakePolicy.new(true))
19
+
20
+ assert_equal "\e[31mMISMATCH\e[0m", colorizer.call("MISMATCH", code: "31")
21
+ end
22
+
23
+ def test_returns_text_when_disabled
24
+ colorizer = Csvtool::Interface::CLI::Output::Colorizer.new(policy: FakePolicy.new(false))
25
+
26
+ assert_equal "MATCH", colorizer.call("MATCH", code: "32")
27
+ end
28
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../../test_helper"
4
+ require "csvtool/interface/cli/output/formatters/csv_row_formatter"
5
+
6
+ class CsvRowFormatterTest < Minitest::Test
7
+ def test_formats_row_with_separator
8
+ formatter = Csvtool::Interface::CLI::Output::Formatters::CsvRowFormatter.new
9
+
10
+ row = formatter.call(fields: ["Alice", "London"], col_sep: ",")
11
+
12
+ assert_equal "Alice,London", row
13
+ end
14
+
15
+ def test_quotes_values_when_needed
16
+ formatter = Csvtool::Interface::CLI::Output::Formatters::CsvRowFormatter.new
17
+
18
+ row = formatter.call(fields: ["Alice, Jr", "London"], col_sep: ",")
19
+
20
+ assert_equal '"Alice, Jr",London', row
21
+ end
22
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../../test_helper"
4
+ require "csvtool/interface/cli/output/formatters/stats_formatter"
5
+ require "csvtool/interface/cli/output/table_renderer"
6
+ require "json"
7
+
8
+ class StatsFormatterTest < Minitest::Test
9
+ def sample_data
10
+ {
11
+ row_count: 3,
12
+ column_count: 2,
13
+ headers: ["name", "city"],
14
+ column_stats: [
15
+ { name: "name", non_blank_count: 3, blank_count: 0 },
16
+ { name: "city", non_blank_count: 2, blank_count: 1 }
17
+ ]
18
+ }
19
+ end
20
+
21
+ def formatter
22
+ Csvtool::Interface::CLI::Output::Formatters::StatsFormatter.new(
23
+ table_renderer: Csvtool::Interface::CLI::Output::TableRenderer.new
24
+ )
25
+ end
26
+
27
+ def test_formats_text_summary
28
+ text = formatter.call(data: sample_data, format: "text", max_width: 80)
29
+
30
+ assert_includes text, "CSV Stats Summary"
31
+ assert_includes text, "Metric"
32
+ assert_includes text, "Column completeness:"
33
+ end
34
+
35
+ def test_formats_json_summary
36
+ json = formatter.call(data: sample_data, format: "json", max_width: 80)
37
+
38
+ parsed = JSON.parse(json, symbolize_names: true)
39
+ assert_equal 3, parsed[:row_count]
40
+ assert_equal 2, parsed[:column_count]
41
+ end
42
+
43
+ def test_formats_csv_summary
44
+ csv = formatter.call(data: sample_data, format: "csv", max_width: 80)
45
+
46
+ lines = csv.lines.map(&:chomp)
47
+ assert_equal "metric,value", lines.first
48
+ assert_includes lines, "row_count,3"
49
+ assert_includes lines, "column.city.blank,1"
50
+ end
51
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../test_helper"
4
+ require "csvtool/interface/cli/output/streams"
5
+
6
+ class StreamsTest < Minitest::Test
7
+ def test_builds_data_and_ui_streams
8
+ data = StringIO.new
9
+ ui = StringIO.new
10
+
11
+ streams = Csvtool::Interface::CLI::Output::Streams.build(data: data, ui: ui)
12
+
13
+ assert_same data, streams.data
14
+ assert_same ui, streams.ui
15
+ end
16
+
17
+ def test_defaults_ui_to_data_stream
18
+ data = StringIO.new
19
+
20
+ streams = Csvtool::Interface::CLI::Output::Streams.build(data: data)
21
+
22
+ assert_same data, streams.data
23
+ assert_same data, streams.ui
24
+ end
25
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../test_helper"
4
+ require "csvtool/interface/cli/output/table_renderer"
5
+
6
+ class TableRendererTest < Minitest::Test
7
+ def test_renders_aligned_table
8
+ renderer = Csvtool::Interface::CLI::Output::TableRenderer.new
9
+
10
+ text = renderer.render(
11
+ headers: ["Metric", "Value"],
12
+ rows: [["Rows", "3"], ["Columns", "2"]],
13
+ max_width: 80
14
+ )
15
+
16
+ lines = text.lines.map(&:chomp)
17
+ assert_equal "Metric | Value", lines[0]
18
+ assert_equal "--------+------", lines[1]
19
+ assert_equal "Rows | 3 ", lines[2]
20
+ assert_equal "Columns | 2 ", lines[3]
21
+ end
22
+
23
+ def test_truncates_cells_when_width_is_narrow
24
+ renderer = Csvtool::Interface::CLI::Output::TableRenderer.new
25
+
26
+ text = renderer.render(
27
+ headers: ["Column", "Non-blank", "Blank"],
28
+ rows: [["very_long_column_name", "123456", "0"]],
29
+ max_width: 26
30
+ )
31
+
32
+ lines = text.lines.map(&:chomp)
33
+ assert lines.all? { |line| line.length <= 26 }
34
+ assert_includes lines[2], "..."
35
+ end
36
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/builders/csv_stats_session_builder"
5
+ require "csvtool/domain/shared/output_destination"
6
+
7
+ class CsvStatsSessionBuilderTest < Minitest::Test
8
+ def test_builds_stats_session
9
+ builder = Csvtool::Interface::CLI::Workflows::Builders::CsvStatsSessionBuilder.new
10
+ destination = Csvtool::Domain::Shared::OutputDestination.console
11
+
12
+ session = builder.call(file_path: "/tmp/data.csv", col_sep: ";", headers_present: false, destination: destination)
13
+
14
+ assert_equal "/tmp/data.csv", session.source.path
15
+ assert_equal ";", session.source.separator
16
+ assert_equal false, session.source.headers_present
17
+ assert_equal true, session.output_destination.console?
18
+ end
19
+ end
@@ -14,7 +14,10 @@ class CrossCsvDedupePresenterTest < Minitest::Test
14
14
 
15
15
  assert_includes out.string, "\nid,name\n"
16
16
  assert_includes out.string, "1,Alice"
17
- assert_includes out.string, "Summary: source_rows=5 removed_rows=3 kept_rows=2"
17
+ assert_includes out.string, "Summary"
18
+ assert_includes out.string, "Source rows"
19
+ assert_includes out.string, "Removed rows"
20
+ assert_includes out.string, "Kept rows"
18
21
  end
19
22
 
20
23
  def test_prints_zero_and_all_removed_messages
@@ -17,7 +17,11 @@ class CsvParityPresenterTest < Minitest::Test
17
17
  )
18
18
 
19
19
  assert_includes out.string, "MATCH"
20
- assert_includes out.string, "Summary: left_rows=3 right_rows=3 left_only=0 right_only=0"
20
+ assert_includes out.string, "Metric"
21
+ assert_includes out.string, "Left rows"
22
+ assert_includes out.string, "Right rows"
23
+ assert_includes out.string, "Left only"
24
+ assert_includes out.string, "Right only"
21
25
  end
22
26
 
23
27
  def test_prints_mismatch_examples
@@ -17,10 +17,28 @@ class CsvSplitPresenterTest < Minitest::Test
17
17
  )
18
18
 
19
19
  assert_includes out.string, "Split complete."
20
- assert_includes out.string, "Chunk size: 10"
21
- assert_includes out.string, "Data rows: 25"
22
- assert_includes out.string, "Chunks written: 3"
23
- assert_includes out.string, "Manifest: /tmp/manifest.csv"
20
+ assert_includes out.string, "Metric"
21
+ assert_includes out.string, "Chunk size"
22
+ assert_includes out.string, "Data rows"
23
+ assert_includes out.string, "Chunks written"
24
+ assert_includes out.string, "Manifest"
24
25
  assert_includes out.string, "/tmp/people_part_001.csv"
25
26
  end
27
+
28
+ def test_truncates_summary_table_for_narrow_width
29
+ out = StringIO.new
30
+ presenter = Csvtool::Interface::CLI::Workflows::Presenters::CsvSplitPresenter.new(stdout: out, max_width: 26)
31
+
32
+ presenter.print_summary(
33
+ chunk_size: 10,
34
+ data_rows: 25,
35
+ chunk_count: 3,
36
+ manifest_path: "/tmp/very/long/path/manifest.csv",
37
+ chunk_paths: []
38
+ )
39
+
40
+ lines = out.string.lines.map(&:chomp)
41
+ assert lines.all? { |line| line.empty? || line.length <= 26 }
42
+ assert_includes out.string, "..."
43
+ end
26
44
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../../test_helper"
4
+ require "csvtool/interface/cli/workflows/presenters/csv_stats_presenter"
5
+
6
+ class CsvStatsPresenterTest < Minitest::Test
7
+ def test_prints_summary_with_headers_and_column_stats
8
+ out = StringIO.new
9
+ presenter = Csvtool::Interface::CLI::Workflows::Presenters::CsvStatsPresenter.new(stdout: out)
10
+
11
+ presenter.print_summary(
12
+ row_count: 3,
13
+ column_count: 2,
14
+ headers: ["name", "city"],
15
+ column_stats: [
16
+ { name: "name", non_blank_count: 3, blank_count: 0 },
17
+ { name: "city", non_blank_count: 2, blank_count: 1 }
18
+ ]
19
+ )
20
+
21
+ assert_includes out.string, "CSV Stats Summary"
22
+ assert_includes out.string, "Metric"
23
+ assert_includes out.string, "Rows"
24
+ assert_includes out.string, "Columns"
25
+ assert_includes out.string, "Headers"
26
+ assert_includes out.string, "Column completeness:"
27
+ assert_includes out.string, "name"
28
+ assert_includes out.string, "city"
29
+ end
30
+
31
+ def test_prints_file_written_message
32
+ out = StringIO.new
33
+ presenter = Csvtool::Interface::CLI::Workflows::Presenters::CsvStatsPresenter.new(stdout: out)
34
+
35
+ presenter.print_file_written("/tmp/stats.csv")
36
+
37
+ assert_includes out.string, "Wrote output to /tmp/stats.csv"
38
+ end
39
+ end