normalized_match 0.1.0
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +132 -0
- data/lib/normalized_match/normalized_match_matcher.rb +190 -0
- data/lib/normalized_match.rb +15 -0
- data/normalized_match.gemspec +26 -0
- metadata +67 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: d8557ff5b699123c45a96ea1cf0f01f5600d0a4eb545861b6d30e64c41a07288
|
4
|
+
data.tar.gz: 3fd9d9f8754a5f76073709f9666f35423bca7a49ccc4d87c7672102d126bb169
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 0302d4db823ceb4f60c5e3949f25bdd67f3673539d4da52b54d6e1cbf0a3c28e38e61bbc1b99a5d5bde95d8e7da37fe973078454865c82030c981aeba2d84801
|
7
|
+
data.tar.gz: bf5dec8b78ccc0ce2c574c92ec7663f3367581ca3e86ed8005f414bf618d2b6c35f30aee9a4ff323081048c72442cc5cece22955a5520c6b5c7645e2a7f04b42
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 FIRSTDRAFT, LLC
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
# Normalized Match
|
2
|
+
|
3
|
+
A normalized string matcher for RSpec that ignores case, punctuation, and some whitespace differences.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem "normalized_match"
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
```
|
16
|
+
$ bundle install
|
17
|
+
```
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
```
|
22
|
+
$ gem install normalized_match
|
23
|
+
```
|
24
|
+
|
25
|
+
## Usage
|
26
|
+
|
27
|
+
```ruby
|
28
|
+
require "normalized_match"
|
29
|
+
|
30
|
+
RSpec.describe "My test" do
|
31
|
+
it "matches strings normalized" do
|
32
|
+
actual = "Hello, World!"
|
33
|
+
expected = "hello world"
|
34
|
+
|
35
|
+
expect(actual).to normalized_match(expected)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
```
|
39
|
+
|
40
|
+
The `normalized_match` matcher normalizes both strings by:
|
41
|
+
|
42
|
+
- Converting to lowercase.
|
43
|
+
- Removing all punctuation.
|
44
|
+
- Collapsing internal whitespace (multiple spaces/tabs become single spaces).
|
45
|
+
- Preserving line breaks for multi-line comparisons.
|
46
|
+
- Stripping leading and trailing whitespace from lines.
|
47
|
+
|
48
|
+
When a match fails, it displays a helpful side-by-side comparison table showing both the normalized and original values.
|
49
|
+
|
50
|
+
## Using Heredocs
|
51
|
+
|
52
|
+
When testing output that contains multiple lines, you can use Ruby's squiggly heredoc (`<<~`) to maintain nice indentation in your test files while automatically stripping leading whitespace:
|
53
|
+
|
54
|
+
```ruby
|
55
|
+
RSpec.describe "Letter counter" do
|
56
|
+
it "displays the letter count analysis" do
|
57
|
+
# Your actual output might come from a method, script, etc.
|
58
|
+
actual = run_codeblock(filename)
|
59
|
+
|
60
|
+
# Use squiggly heredoc for expected output
|
61
|
+
# This strips leading whitespace while preserving internal formatting
|
62
|
+
expected = <<~EXPECTED
|
63
|
+
Letter count:
|
64
|
+
H: 1
|
65
|
+
e: 1
|
66
|
+
l: 3
|
67
|
+
o: 2
|
68
|
+
W: 1
|
69
|
+
r: 1
|
70
|
+
d: 1
|
71
|
+
EXPECTED
|
72
|
+
|
73
|
+
expect(actual).to normalized_match(expected)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
```
|
77
|
+
|
78
|
+
**Important:** The squiggly heredoc (`<<~`) technique is especially useful because newlines _within_ the expected content are significant to the matcher. In other words, the above test would not pass if it was written as:
|
79
|
+
|
80
|
+
```ruby
|
81
|
+
expected = "Letter count: H: 1 e: 1 l: 3 o: 2 W: 1 r: 1 d: 1"
|
82
|
+
```
|
83
|
+
|
84
|
+
And the actual output was:
|
85
|
+
|
86
|
+
```
|
87
|
+
Letter count:
|
88
|
+
H: 1
|
89
|
+
e: 1
|
90
|
+
l: 3
|
91
|
+
o: 2
|
92
|
+
W: 1
|
93
|
+
r: 1
|
94
|
+
d: 1
|
95
|
+
```
|
96
|
+
|
97
|
+
## Example Output
|
98
|
+
|
99
|
+
When strings don't match after normalization:
|
100
|
+
|
101
|
+
```
|
102
|
+
Normalized match failed!
|
103
|
+
|
104
|
+
To make it easier to match the expected output,
|
105
|
+
we are "normalizing" both the actual output and
|
106
|
+
expected output in this test. That means we
|
107
|
+
lowercased, removed punctuation, and compacted
|
108
|
+
whitespace in both.
|
109
|
+
|
110
|
+
But the actual output still doesn't contain the
|
111
|
+
expected output. Can you spot the difference?
|
112
|
+
|
113
|
+
╔════════════╦══════════════════╦══════════════════╗
|
114
|
+
║ ║ EXPECTED ║ ACTUAL ║
|
115
|
+
╠════════════╬══════════════════╬══════════════════╣
|
116
|
+
║ NORMALIZED ║ hello world ║ goodbye world ║
|
117
|
+
╠════════════╬══════════════════╬══════════════════╣
|
118
|
+
║ ORIGINAL ║ Hello, World! ║ Goodbye, World! ║
|
119
|
+
╚════════════╩══════════════════╩══════════════════╝
|
120
|
+
```
|
121
|
+
|
122
|
+
## Development
|
123
|
+
|
124
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/rake` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
125
|
+
|
126
|
+
## Contributing
|
127
|
+
|
128
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/firstdraft/normalized_match.
|
129
|
+
|
130
|
+
## License
|
131
|
+
|
132
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,190 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NormalizedMatch
|
4
|
+
module NormalizedMatchMatcher
|
5
|
+
# Constants for table formatting
|
6
|
+
COLUMN_PADDING = 4 # 2 spaces on each side
|
7
|
+
LABEL_PADDING = 2 # For borders in label column
|
8
|
+
RSpec::Matchers.define :normalized_match do |expected|
|
9
|
+
match do |actual|
|
10
|
+
# Store the original values
|
11
|
+
@original_expected = expected
|
12
|
+
@original_actual = actual
|
13
|
+
|
14
|
+
# Store normalized versions for cleaner diffs
|
15
|
+
@expected = normalize_string(expected)
|
16
|
+
@actual = normalize_string(actual)
|
17
|
+
|
18
|
+
# Check if actual contains expected (substring match)
|
19
|
+
@actual.include?(@expected)
|
20
|
+
end
|
21
|
+
|
22
|
+
failure_message do |actual|
|
23
|
+
# Split into lines for side-by-side comparison
|
24
|
+
orig_expected_lines = @original_expected.to_s.split("\n")
|
25
|
+
orig_actual_lines = @original_actual.to_s.split("\n")
|
26
|
+
norm_expected_lines = @expected.to_s.split("\n")
|
27
|
+
norm_actual_lines = @actual.to_s.split("\n")
|
28
|
+
|
29
|
+
# Check if normalization changed anything
|
30
|
+
expected_changed = @original_expected.to_s != @expected.to_s
|
31
|
+
actual_changed = @original_actual.to_s != @actual.to_s
|
32
|
+
normalization_needed = expected_changed || actual_changed
|
33
|
+
|
34
|
+
# Calculate column widths
|
35
|
+
max_orig_expected = orig_expected_lines.map(&:length).max || 0
|
36
|
+
max_orig_actual = orig_actual_lines.map(&:length).max || 0
|
37
|
+
max_norm_expected = norm_expected_lines.map(&:length).max || 0
|
38
|
+
max_norm_actual = norm_actual_lines.map(&:length).max || 0
|
39
|
+
|
40
|
+
# Ensure minimum column width for headers
|
41
|
+
expected_col_width = [max_orig_expected, max_norm_expected, "EXPECTED".length].max + COLUMN_PADDING
|
42
|
+
actual_col_width = [max_orig_actual, max_norm_actual, "ACTUAL".length].max + COLUMN_PADDING
|
43
|
+
|
44
|
+
# Calculate label column width (for "NORMALIZED" and "ORIGINAL")
|
45
|
+
label_col_width = ["NORMALIZED".length, "ORIGINAL".length].max + COLUMN_PADDING
|
46
|
+
|
47
|
+
# Build the side-by-side table components
|
48
|
+
|
49
|
+
# Helper to pad and center text
|
50
|
+
def center_pad(text, width)
|
51
|
+
text = text.to_s
|
52
|
+
padding = width - text.length
|
53
|
+
left_pad = padding / 2
|
54
|
+
right_pad = padding - left_pad
|
55
|
+
" " * left_pad + text + " " * right_pad
|
56
|
+
end
|
57
|
+
|
58
|
+
# Helper to left-pad text with 2 spaces on each side
|
59
|
+
def pad_line(text, width)
|
60
|
+
" #{text.to_s.ljust(width - COLUMN_PADDING)} "
|
61
|
+
end
|
62
|
+
|
63
|
+
# Build header output
|
64
|
+
header_output = []
|
65
|
+
header_output << "Normalized match failed!"
|
66
|
+
header_output << ""
|
67
|
+
|
68
|
+
if normalization_needed
|
69
|
+
header_output << "To make it easier to match the expected output,"
|
70
|
+
header_output << "we are \"normalizing\" both the actual output and"
|
71
|
+
header_output << "expected output in this test. That means we"
|
72
|
+
header_output << "lowercased, removed punctuation, and compacted"
|
73
|
+
header_output << "whitespace in both."
|
74
|
+
header_output << ""
|
75
|
+
header_output << "But the actual output still doesn't contain the"
|
76
|
+
header_output << "expected output. Can you spot the difference?"
|
77
|
+
header_output << ""
|
78
|
+
else
|
79
|
+
header_output << "The actual output doesn't contain the expected"
|
80
|
+
header_output << "output. Can you spot the difference?"
|
81
|
+
header_output << ""
|
82
|
+
end
|
83
|
+
|
84
|
+
# Helper to center label text (NORMALIZED/ORIGINAL) in first column
|
85
|
+
def center_label(label, width, total_rows)
|
86
|
+
middle_row = total_rows / 2
|
87
|
+
(0...total_rows).map do |i|
|
88
|
+
if i == middle_row
|
89
|
+
label.center(width)
|
90
|
+
else
|
91
|
+
" " * width
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Build the table
|
97
|
+
table_output = []
|
98
|
+
|
99
|
+
if normalization_needed
|
100
|
+
# Build two-section table with labels
|
101
|
+
# Top border
|
102
|
+
table_output << "╔" + "═" * label_col_width + "╦" + "═" * expected_col_width + "╦" + "═" * actual_col_width + "╗"
|
103
|
+
|
104
|
+
# Header row
|
105
|
+
table_output << "║" + " " * label_col_width + "║" + center_pad("EXPECTED", expected_col_width) + "║" + center_pad("ACTUAL", actual_col_width) + "║"
|
106
|
+
|
107
|
+
# Header separator
|
108
|
+
table_output << "╠" + "═" * label_col_width + "╬" + "═" * expected_col_width + "╬" + "═" * actual_col_width + "╣"
|
109
|
+
|
110
|
+
# NORMALIZED section
|
111
|
+
max_norm_lines = [norm_expected_lines.length, norm_actual_lines.length].max
|
112
|
+
normalized_labels = center_label("NORMALIZED", label_col_width - LABEL_PADDING, max_norm_lines)
|
113
|
+
|
114
|
+
max_norm_lines.times do |i|
|
115
|
+
expected_line = norm_expected_lines[i] || ""
|
116
|
+
actual_line = norm_actual_lines[i] || ""
|
117
|
+
label = normalized_labels[i]
|
118
|
+
table_output << "║ #{label} ║" + pad_line(expected_line, expected_col_width) + "║" + pad_line(actual_line, actual_col_width) + "║"
|
119
|
+
end
|
120
|
+
|
121
|
+
# Middle separator
|
122
|
+
table_output << "╠" + "═" * label_col_width + "╬" + "═" * expected_col_width + "╬" + "═" * actual_col_width + "╣"
|
123
|
+
|
124
|
+
# ORIGINAL section
|
125
|
+
max_lines = [orig_expected_lines.length, orig_actual_lines.length].max
|
126
|
+
original_labels = center_label("ORIGINAL", label_col_width - LABEL_PADDING, max_lines)
|
127
|
+
|
128
|
+
max_lines.times do |i|
|
129
|
+
expected_line = orig_expected_lines[i] || ""
|
130
|
+
actual_line = orig_actual_lines[i] || ""
|
131
|
+
label = original_labels[i]
|
132
|
+
table_output << "║ #{label} ║" + pad_line(expected_line, expected_col_width) + "║" + pad_line(actual_line, actual_col_width) + "║"
|
133
|
+
end
|
134
|
+
|
135
|
+
# Bottom border
|
136
|
+
table_output << "╚" + "═" * label_col_width + "╩" + "═" * expected_col_width + "╩" + "═" * actual_col_width + "╝"
|
137
|
+
else
|
138
|
+
# Build simple table without labels column
|
139
|
+
# Top border
|
140
|
+
table_output << "╔" + "═" * expected_col_width + "╦" + "═" * actual_col_width + "╗"
|
141
|
+
|
142
|
+
# Header row
|
143
|
+
table_output << "║" + center_pad("EXPECTED", expected_col_width) + "║" + center_pad("ACTUAL", actual_col_width) + "║"
|
144
|
+
|
145
|
+
# Header separator
|
146
|
+
table_output << "╠" + "═" * expected_col_width + "╬" + "═" * actual_col_width + "╣"
|
147
|
+
|
148
|
+
# Data rows (using original since no normalization happened)
|
149
|
+
max_lines = [orig_expected_lines.length, orig_actual_lines.length].max
|
150
|
+
|
151
|
+
max_lines.times do |i|
|
152
|
+
expected_line = orig_expected_lines[i] || ""
|
153
|
+
actual_line = orig_actual_lines[i] || ""
|
154
|
+
table_output << "║" + pad_line(expected_line, expected_col_width) + "║" + pad_line(actual_line, actual_col_width) + "║"
|
155
|
+
end
|
156
|
+
|
157
|
+
# Bottom border
|
158
|
+
table_output << "╚" + "═" * expected_col_width + "╩" + "═" * actual_col_width + "╝"
|
159
|
+
end
|
160
|
+
|
161
|
+
# Combine all parts - header, then table (with extra newline between)
|
162
|
+
# Add a trailing newline so there's a blank line before RSpec's diff (when it appears)
|
163
|
+
message = header_output.join("\n") + "\n" + table_output.join("\n") + "\n"
|
164
|
+
|
165
|
+
message
|
166
|
+
end
|
167
|
+
|
168
|
+
# These methods make the matcher work better with rspec-enriched_json
|
169
|
+
attr_reader :expected, :actual
|
170
|
+
|
171
|
+
# Provide access to original values if needed
|
172
|
+
attr_reader :original_expected, :original_actual
|
173
|
+
|
174
|
+
# Tell RSpec that expected and actual can be diffed
|
175
|
+
def diffable?
|
176
|
+
true
|
177
|
+
end
|
178
|
+
|
179
|
+
def normalize_string(str)
|
180
|
+
str.to_s
|
181
|
+
.gsub(/[^a-zA-Z0-9\s]/, "") # Remove punctuation but keep all whitespace
|
182
|
+
.downcase
|
183
|
+
.split("\n") # Split by newlines to preserve them
|
184
|
+
.map { |line| line.gsub(/\s+/, " ").strip } # Normalize spaces within each line
|
185
|
+
.reject(&:empty?) # Remove empty lines
|
186
|
+
.join("\n") # Rejoin with newlines
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rspec/matchers"
|
4
|
+
require_relative "normalized_match/normalized_match_matcher"
|
5
|
+
|
6
|
+
# Main namespace.
|
7
|
+
module NormalizedMatch
|
8
|
+
end
|
9
|
+
|
10
|
+
# Include the matcher in RSpec when this gem is loaded
|
11
|
+
if defined?(RSpec)
|
12
|
+
RSpec.configure do |config|
|
13
|
+
config.include NormalizedMatch::NormalizedMatchMatcher
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = "normalized_match"
|
5
|
+
spec.version = "0.1.0"
|
6
|
+
spec.authors = ["Raghu Betina"]
|
7
|
+
spec.email = ["raghu@firstdraft.com"]
|
8
|
+
spec.homepage = "https://github.com/firstdraft/normalized_match"
|
9
|
+
spec.summary = "A normalized string matcher for RSpec that ignores case, punctuation, and whitespace differences"
|
10
|
+
spec.license = "MIT"
|
11
|
+
|
12
|
+
spec.metadata = {
|
13
|
+
"bug_tracker_uri" => "https://github.com/firstdraft/normalized_match/issues",
|
14
|
+
"changelog_uri" => "https://github.com/firstdraft/normalized_match/blob/main/CHANGELOG.md",
|
15
|
+
"homepage_uri" => "https://github.com/firstdraft/normalized_match",
|
16
|
+
"label" => "Normalized Match",
|
17
|
+
"rubygems_mfa_required" => "true",
|
18
|
+
"source_code_uri" => "https://github.com/firstdraft/normalized_match"
|
19
|
+
}
|
20
|
+
|
21
|
+
spec.required_ruby_version = ">= 3.0"
|
22
|
+
spec.add_dependency "rspec", "~> 3.0"
|
23
|
+
|
24
|
+
spec.extra_rdoc_files = Dir["README*", "LICENSE*"]
|
25
|
+
spec.files = Dir["*.gemspec", "lib/**/*"]
|
26
|
+
end
|
metadata
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: normalized_match
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Raghu Betina
|
8
|
+
bindir: bin
|
9
|
+
cert_chain: []
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: rspec
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: '3.0'
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - "~>"
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: '3.0'
|
26
|
+
email:
|
27
|
+
- raghu@firstdraft.com
|
28
|
+
executables: []
|
29
|
+
extensions: []
|
30
|
+
extra_rdoc_files:
|
31
|
+
- LICENSE
|
32
|
+
- README.md
|
33
|
+
files:
|
34
|
+
- LICENSE
|
35
|
+
- README.md
|
36
|
+
- lib/normalized_match.rb
|
37
|
+
- lib/normalized_match/normalized_match_matcher.rb
|
38
|
+
- normalized_match.gemspec
|
39
|
+
homepage: https://github.com/firstdraft/normalized_match
|
40
|
+
licenses:
|
41
|
+
- MIT
|
42
|
+
metadata:
|
43
|
+
bug_tracker_uri: https://github.com/firstdraft/normalized_match/issues
|
44
|
+
changelog_uri: https://github.com/firstdraft/normalized_match/blob/main/CHANGELOG.md
|
45
|
+
homepage_uri: https://github.com/firstdraft/normalized_match
|
46
|
+
label: Normalized Match
|
47
|
+
rubygems_mfa_required: 'true'
|
48
|
+
source_code_uri: https://github.com/firstdraft/normalized_match
|
49
|
+
rdoc_options: []
|
50
|
+
require_paths:
|
51
|
+
- lib
|
52
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: '3.0'
|
57
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
requirements: []
|
63
|
+
rubygems_version: 3.6.9
|
64
|
+
specification_version: 4
|
65
|
+
summary: A normalized string matcher for RSpec that ignores case, punctuation, and
|
66
|
+
whitespace differences
|
67
|
+
test_files: []
|