yarn_skein 0.1.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4fdd27e3f715f8b7f200f6bd5f604e503dad0bb61cb2d0f8cd0100680c902e77
4
- data.tar.gz: 0a121d542d7559e6a5b1c6ef36df30f0a7676c0b64f25c3194eaf90124c8cf7b
3
+ metadata.gz: 2efd2c01dfa8a4654b1f110487399eb0111dba77daf030e7d3826d3514531a75
4
+ data.tar.gz: ea44d7874a49e18995a51e9ca09a04c787bf43db2f49a79281213f4679db14bb
5
5
  SHA512:
6
- metadata.gz: 2a0f0c9d824f8689a18265c0d20dde24267649a4ff2f0d174330dff7f0de0e9e60575075d9f25fe32865fa3c9530f206c2f72652afbad050bb5070ce3f28ed10
7
- data.tar.gz: 76e392350e32d039c950b9b9680b027e2554982335e8b2c01abe2f76f30e29dae1dca3630b4316411e41eff25d28659591f7fb573f6932505036e6e05c2a2925
6
+ metadata.gz: 737cb9cedba8020ac8d5050d771a3051f43fa56a1a55c634120dbb77d1a977e22e0affb71d5b4746fa106c488468a19a737a4723319eb71e85a06722ab2d07fa
7
+ data.tar.gz: e52704d9b994e919071302371e97c0d7d56fe0b72e64c26c01d71015211e520566a6406d1de8c259c5826b10614d2a748c69d0852f9f62c09c275c2a50c23a76
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-03-26
4
+
5
+ ### Added
6
+ - `YarnSkein::Catalog` for loading yarn data from YAML seed files with filtering by brand, weight category, and fiber content
7
+ - `YarnSkein::YardageEstimator` for estimating total yarn yardage from gauge and dimensions
8
+ - Rectangular piece estimation via `for_rectangle(width:, height:)`
9
+ - Stitch/row count estimation via `for_piece(stitches:, rows:)`
10
+ - Configurable safety margin (default 10%) for waste and joining
11
+ - Added `fiber_gauge` as a runtime dependency
12
+
13
+ ## [0.2.0] - 2026-03-21
14
+
15
+ ### Added
16
+ - `YarnSkein::Substitution` for finding compatible yarn substitutes from a catalog
17
+ - Matches by weight category and grist tolerance (default 15%, configurable)
18
+ - Optional fiber content filter
19
+ - Added missing `rake` and `simplecov-json` dev dependencies
3
20
 
4
21
  ## [0.1.1] - 2026-03-14
5
22
  ### Added
data/Rakefile CHANGED
@@ -1,58 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
4
- require "rspec/core/rake_task"
5
- require "yard"
6
- require "yard/rake/yardoc_task"
7
- require "standard/rake"
3
+ require "rake/testtask"
8
4
 
9
- # YARD Documentation tasks
10
- begin
11
- namespace :doc do
12
- desc "Generate YARD documentation"
13
- YARD::Rake::YardocTask.new(:generate) do |t|
14
- t.files = ["lib/**/*.rb", "-", "README.md"]
15
- t.options = [
16
- "--output-dir", "doc/yard",
17
- "--markup", "markdown",
18
- "--title", "YarnSkein - Yarn metadata and skein calculations",
19
- "--readme", "README.md"
20
- ]
21
- end
22
-
23
- desc "Regenerate documentation with cache reset"
24
- task regenerate: ["doc:clean", "doc:generate"]
25
-
26
- desc "Clean generated documentation"
27
- task :clean do
28
- rm_rf "doc/yard"
29
- rm_rf ".yardoc"
30
- end
31
-
32
- desc "Start YARD server for local documentation viewing"
33
- task :serve do
34
- sh "bundle exec yard server --reload --port 8808"
35
- end
36
-
37
- desc "Validate YARD documentation coverage"
38
- task :coverage do
39
- sh "bundle exec yard stats --list-undoc"
40
- end
41
-
42
- desc "Generate complete documentation with coverage report"
43
- task complete: [:generate, :coverage]
44
- end
45
-
46
- # Add shorthand aliases
47
- task yard: "doc:generate"
48
- task yard_server: "doc:serve"
49
- task yard_clean: "doc:clean"
50
- rescue LoadError
51
- # YARD is only available in development/test environments
52
- # Silence this warning in production where it's not needed
5
+ Rake::TestTask.new do |t|
6
+ t.libs << "test"
7
+ t.pattern = "test/**/*_test.rb"
53
8
  end
54
9
 
55
- task :default do
56
- Rake::Task["standard"].invoke
57
- Rake::Task["spec"].invoke
58
- end
10
+ task default: :test
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module YarnSkein
6
+ class Catalog
7
+ def initialize(data_dir: nil)
8
+ @data_dir = data_dir || default_data_dir
9
+ @yarns = nil
10
+ end
11
+
12
+ def all
13
+ load_yarns
14
+ end
15
+
16
+ def filter(brand: nil, weight_category: nil, fiber: nil)
17
+ results = all
18
+ results = results.select { |y| y.brand.downcase == brand.downcase } if brand
19
+ results = results.select { |y| y.weight_category == weight_category.to_sym } if weight_category
20
+ results = results.select { |y| y.fiber_content&.contains?(fiber.to_sym) } if fiber
21
+ results
22
+ end
23
+
24
+ def brands
25
+ all.map(&:brand).uniq.sort
26
+ end
27
+
28
+ def find(brand:, line:)
29
+ all.detect { |y| y.brand.downcase == brand.downcase && y.line.downcase == line.downcase }
30
+ end
31
+
32
+ private
33
+
34
+ def load_yarns
35
+ @yarns ||= Dir.glob(File.join(@data_dir, "*.yml")).reject { |f| File.basename(f).start_with?("_") }.flat_map { |file| parse_brand_file(file) }
36
+ end
37
+
38
+ def parse_brand_file(file)
39
+ data = YAML.safe_load_file(file, permitted_classes: [])
40
+ brand_name = data["brand"]
41
+ (data["yarns"] || []).filter_map do |entry|
42
+ build_yarn(brand_name, entry)
43
+ end
44
+ end
45
+
46
+ def build_yarn(brand_name, entry)
47
+ yardage = entry["yardage"]
48
+ skein_weight = entry["skein_weight"]
49
+ return nil unless yardage && skein_weight
50
+
51
+ fiber_content = nil
52
+ if entry["fiber_content"] && !entry["fiber_content"].empty?
53
+ fiber_hash = entry["fiber_content"].transform_keys(&:to_sym).transform_values(&:to_i)
54
+ fiber_content = FiberBlend.new(fiber_hash)
55
+ end
56
+
57
+ Yarn.new(
58
+ brand: brand_name,
59
+ line: entry["line"],
60
+ yardage: yardage.to_f.yards,
61
+ skein_weight: skein_weight.to_f.grams,
62
+ fiber_content: fiber_content
63
+ )
64
+ end
65
+
66
+ def default_data_dir
67
+ env_path = ENV["YARN_CATALOG_PATH"]
68
+ return env_path if env_path
69
+
70
+ File.expand_path("../../../../data/yarns", __dir__)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,30 @@
1
+ module YarnSkein
2
+ class Substitution
3
+ DEFAULT_TOLERANCE = 0.15
4
+
5
+ attr_reader :target, :catalog
6
+
7
+ def initialize(target:, catalog:)
8
+ @target = target
9
+ @catalog = catalog
10
+ end
11
+
12
+ def matches(tolerance: DEFAULT_TOLERANCE, fiber: nil)
13
+ catalog.select do |yarn|
14
+ next false if yarn == target
15
+ next false unless yarn.weight_category == target.weight_category
16
+ next false unless within_grist_tolerance?(yarn, tolerance)
17
+ next false if fiber && !yarn.fiber_content&.contains?(fiber)
18
+
19
+ true
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def within_grist_tolerance?(yarn, tolerance)
26
+ ratio = yarn.yards_per_100g / target.yards_per_100g.to_f
27
+ ratio.between?(1.0 - tolerance, 1.0 + tolerance)
28
+ end
29
+ end
30
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # :nocov:
3
4
  module YarnSkein
4
5
  # Current gem version.
5
- VERSION = "0.1.1"
6
+ VERSION = "0.3.0"
6
7
  end
8
+ # :nocov:
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YarnSkein
4
+ # Estimates total yarn yardage required for a project based on gauge and yarn.
5
+ #
6
+ # Uses a geometric stitch model: each knit stitch consumes approximately
7
+ # one stitch-width plus two stitch-heights of yarn (the U-shaped loop path).
8
+ # This gives a reasonable estimate for stockinette and similar stitch patterns.
9
+ #
10
+ # @example
11
+ # estimator = YarnSkein::YardageEstimator.new(gauge: gauge, yarn: yarn)
12
+ # estimator.for_rectangle(width: 60.inches, height: 72.inches)
13
+ # # => { yardage: <FiberUnits::Length>, skeins: 10 }
14
+ class YardageEstimator
15
+ # Default safety margin added to yardage estimates (10%).
16
+ DEFAULT_MARGIN = 0.10
17
+
18
+ # @return [FiberGauge::Gauge] gauge used for stitch/row calculations
19
+ attr_reader :gauge
20
+
21
+ # @return [YarnSkein::Yarn] yarn used for skein calculations
22
+ attr_reader :yarn
23
+
24
+ # @param gauge [FiberGauge::Gauge] gauge swatch data
25
+ # @param yarn [YarnSkein::Yarn] yarn being used
26
+ def initialize(gauge:, yarn:)
27
+ @gauge = gauge
28
+ @yarn = yarn
29
+ end
30
+
31
+ # Estimates yardage for a rectangular piece (scarf, blanket, etc.).
32
+ #
33
+ # @param width [FiberUnits::Length] finished width
34
+ # @param height [FiberUnits::Length] finished height
35
+ # @param margin [Float] safety margin as a decimal (default 0.10 = 10%)
36
+ # @return [Hash] :yardage [FiberUnits::Length] and :skeins [Integer]
37
+ def for_rectangle(width:, height:, margin: DEFAULT_MARGIN)
38
+ stitches = gauge.required_stitches(width)
39
+ rows = gauge.required_rows(height)
40
+
41
+ for_piece(stitches: stitches, rows: rows, margin: margin)
42
+ end
43
+
44
+ # Estimates yardage from stitch and row counts directly.
45
+ #
46
+ # @param stitches [FiberUnits::StitchCount] total stitches across
47
+ # @param rows [FiberUnits::RowCount] total rows
48
+ # @param margin [Float] safety margin as a decimal (default 0.10 = 10%)
49
+ # @return [Hash] :yardage [FiberUnits::Length] and :skeins [Integer]
50
+ def for_piece(stitches:, rows:, margin: DEFAULT_MARGIN)
51
+ total_stitches = stitches.value * rows.value
52
+ yardage = (total_stitches * yards_per_stitch * (1.0 + margin)).yards
53
+
54
+ {yardage: yardage, skeins: yarn.skeins_required(yardage)}
55
+ end
56
+
57
+ # Returns the estimated yards of yarn consumed per stitch.
58
+ #
59
+ # Models each stitch as a U-shaped loop: one stitch-width across plus
60
+ # two stitch-heights for the legs of the loop.
61
+ #
62
+ # @return [Float] yards per stitch
63
+ def yards_per_stitch
64
+ stitch_width_in = 1.0 / gauge.spi
65
+ stitch_height_in = 1.0 / gauge.rpi
66
+ yarn_per_stitch_in = stitch_width_in + (2.0 * stitch_height_in)
67
+
68
+ yarn_per_stitch_in / 36.0
69
+ end
70
+ end
71
+ end
data/lib/yarn_skein.rb CHANGED
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fiber_units"
4
+ require "fiber_gauge"
4
5
  require_relative "yarn_skein/version"
5
6
  require_relative "yarn_skein/weight_category"
6
7
  require_relative "yarn_skein/fiber_blend"
7
8
  require_relative "yarn_skein/yarn"
9
+ require_relative "yarn_skein/substitution"
10
+ require_relative "yarn_skein/yardage_estimator"
11
+ require_relative "yarn_skein/catalog"
8
12
 
9
13
  # Domain objects for describing yarn skeins, fiber blends, and weight classes.
10
14
  #
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "tmpdir"
5
+ require "yaml"
6
+ require "fileutils"
7
+
8
+ class CatalogTest < Minitest::Test
9
+ def setup
10
+ @data_dir = Dir.mktmpdir("catalog_test")
11
+ write_fixture("malabrigo.yml", {
12
+ "brand" => "Malabrigo",
13
+ "slug" => "malabrigo",
14
+ "yarns" => [
15
+ {
16
+ "line" => "Rios",
17
+ "slug" => "rios",
18
+ "weight_category" => "worsted",
19
+ "yardage" => 210,
20
+ "skein_weight" => 100,
21
+ "fiber_content" => {"wool" => 100},
22
+ "colorways" => []
23
+ },
24
+ {
25
+ "line" => "Mechita",
26
+ "slug" => "mechita",
27
+ "weight_category" => "fingering",
28
+ "yardage" => 420,
29
+ "skein_weight" => 100,
30
+ "fiber_content" => {"wool" => 100},
31
+ "colorways" => []
32
+ }
33
+ ]
34
+ })
35
+
36
+ write_fixture("cascade.yml", {
37
+ "brand" => "Cascade",
38
+ "slug" => "cascade",
39
+ "yarns" => [
40
+ {
41
+ "line" => "220 Superwash",
42
+ "slug" => "220-superwash",
43
+ "weight_category" => "worsted",
44
+ "yardage" => 200,
45
+ "skein_weight" => 100,
46
+ "fiber_content" => {"wool" => 100},
47
+ "colorways" => []
48
+ }
49
+ ]
50
+ })
51
+
52
+ # Meta file should be ignored
53
+ write_fixture("_meta.yml", {"scraped_at" => "2026-01-01", "version" => "1.0"})
54
+ end
55
+
56
+ def teardown
57
+ FileUtils.rm_rf(@data_dir)
58
+ end
59
+
60
+ # ---- all ----
61
+
62
+ def test_all_returns_yarn_objects
63
+ catalog = YarnSkein::Catalog.new(data_dir: @data_dir)
64
+ yarns = catalog.all
65
+
66
+ assert_equal 3, yarns.size
67
+ assert yarns.all? { |y| y.is_a?(YarnSkein::Yarn) }
68
+ end
69
+
70
+ def test_all_skips_meta_files
71
+ catalog = YarnSkein::Catalog.new(data_dir: @data_dir)
72
+ brands = catalog.all.map(&:brand).uniq
73
+
74
+ refute_includes brands, "scraped_at"
75
+ end
76
+
77
+ # ---- filter ----
78
+
79
+ def test_filter_by_brand
80
+ catalog = YarnSkein::Catalog.new(data_dir: @data_dir)
81
+ results = catalog.filter(brand: "Malabrigo")
82
+
83
+ assert_equal 2, results.size
84
+ assert results.all? { |y| y.brand == "Malabrigo" }
85
+ end
86
+
87
+ def test_filter_by_weight_category
88
+ catalog = YarnSkein::Catalog.new(data_dir: @data_dir)
89
+ results = catalog.filter(weight_category: :worsted)
90
+
91
+ assert_equal 2, results.size
92
+ assert results.all? { |y| y.weight_category == :worsted }
93
+ end
94
+
95
+ def test_filter_by_fiber
96
+ catalog = YarnSkein::Catalog.new(data_dir: @data_dir)
97
+ results = catalog.filter(fiber: :wool)
98
+
99
+ assert_equal 3, results.size
100
+ end
101
+
102
+ def test_filter_combined
103
+ catalog = YarnSkein::Catalog.new(data_dir: @data_dir)
104
+ results = catalog.filter(brand: "Malabrigo", weight_category: :worsted)
105
+
106
+ assert_equal 1, results.size
107
+ assert_equal "Rios", results.first.line
108
+ end
109
+
110
+ # ---- brands ----
111
+
112
+ def test_brands_returns_unique_sorted_list
113
+ catalog = YarnSkein::Catalog.new(data_dir: @data_dir)
114
+
115
+ assert_equal ["Cascade", "Malabrigo"], catalog.brands
116
+ end
117
+
118
+ # ---- find ----
119
+
120
+ def test_find_returns_matching_yarn
121
+ catalog = YarnSkein::Catalog.new(data_dir: @data_dir)
122
+ yarn = catalog.find(brand: "Malabrigo", line: "Rios")
123
+
124
+ assert_equal "Malabrigo", yarn.brand
125
+ assert_equal "Rios", yarn.line
126
+ end
127
+
128
+ def test_find_is_case_insensitive
129
+ catalog = YarnSkein::Catalog.new(data_dir: @data_dir)
130
+ yarn = catalog.find(brand: "malabrigo", line: "rios")
131
+
132
+ assert_equal "Malabrigo", yarn.brand
133
+ end
134
+
135
+ def test_find_returns_nil_when_not_found
136
+ catalog = YarnSkein::Catalog.new(data_dir: @data_dir)
137
+
138
+ assert_nil catalog.find(brand: "Unknown", line: "Yarn")
139
+ end
140
+
141
+ # ---- empty directory ----
142
+
143
+ def test_empty_data_dir
144
+ empty_dir = Dir.mktmpdir("empty_catalog")
145
+ catalog = YarnSkein::Catalog.new(data_dir: empty_dir)
146
+
147
+ assert_empty catalog.all
148
+ assert_empty catalog.brands
149
+ ensure
150
+ FileUtils.rm_rf(empty_dir)
151
+ end
152
+
153
+ # ---- skips entries without yardage ----
154
+
155
+ def test_skips_entries_missing_yardage
156
+ dir = Dir.mktmpdir("incomplete_catalog")
157
+ write_fixture_to(dir, "incomplete.yml", {
158
+ "brand" => "Incomplete",
159
+ "slug" => "incomplete",
160
+ "yarns" => [
161
+ {"line" => "NoYardage", "slug" => "no-yardage", "skein_weight" => 100, "fiber_content" => {}},
162
+ {"line" => "Valid", "slug" => "valid", "yardage" => 200, "skein_weight" => 100, "fiber_content" => {"wool" => 100}}
163
+ ]
164
+ })
165
+
166
+ catalog = YarnSkein::Catalog.new(data_dir: dir)
167
+ assert_equal 1, catalog.all.size
168
+ assert_equal "Valid", catalog.all.first.line
169
+ ensure
170
+ FileUtils.rm_rf(dir)
171
+ end
172
+
173
+ private
174
+
175
+ def write_fixture(filename, data)
176
+ write_fixture_to(@data_dir, filename, data)
177
+ end
178
+
179
+ def write_fixture_to(dir, filename, data)
180
+ File.write(File.join(dir, filename), YAML.dump(data))
181
+ end
182
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class YarnSkeinFiberBlendTest < Minitest::Test
6
+ # -----------------------------
7
+ # Initialization
8
+ # -----------------------------
9
+
10
+ def test_stores_fiber_percentages
11
+ blend = YarnSkein::FiberBlend.new(
12
+ merino_wool: 80,
13
+ nylon: 20
14
+ )
15
+
16
+ assert_equal(
17
+ {
18
+ merino_wool: 80,
19
+ nylon: 20
20
+ },
21
+ blend.fibers
22
+ )
23
+ end
24
+
25
+ def test_raises_error_if_percentages_do_not_sum_to_100
26
+ error = assert_raises(ArgumentError) do
27
+ YarnSkein::FiberBlend.new(
28
+ merino_wool: 50,
29
+ nylon: 30
30
+ )
31
+ end
32
+
33
+ assert_match(/must sum to 100/, error.message)
34
+ end
35
+
36
+ # -----------------------------
37
+ # yards_per_100g
38
+ # -----------------------------
39
+
40
+ def test_calculates_yards_per_100_grams
41
+ yarn = YarnSkein::Yarn.new(
42
+ brand: "Malabrigo",
43
+ line: "Rios",
44
+ yardage: 210.yards,
45
+ skein_weight: 100.grams
46
+ )
47
+
48
+ assert_equal 210, yarn.yards_per_100g
49
+ end
50
+
51
+ # -----------------------------
52
+ # percentage
53
+ # -----------------------------
54
+
55
+ def test_returns_percentage_of_fiber
56
+ blend = YarnSkein::FiberBlend.new(
57
+ merino_wool: 80,
58
+ nylon: 20
59
+ )
60
+
61
+ assert_equal 80, blend.percentage(:merino_wool)
62
+ end
63
+
64
+ def test_returns_zero_for_missing_fibers
65
+ blend = YarnSkein::FiberBlend.new(
66
+ merino_wool: 100
67
+ )
68
+
69
+ assert_equal 0, blend.percentage(:nylon)
70
+ end
71
+
72
+ # -----------------------------
73
+ # contains?
74
+ # -----------------------------
75
+
76
+ def test_contains_returns_true_if_fiber_exists
77
+ blend = YarnSkein::FiberBlend.new(
78
+ merino_wool: 80,
79
+ nylon: 20
80
+ )
81
+
82
+ assert blend.contains?(:nylon)
83
+ end
84
+
85
+ def test_contains_returns_false_if_fiber_does_not_exist
86
+ blend = YarnSkein::FiberBlend.new(
87
+ merino_wool: 100
88
+ )
89
+
90
+ refute blend.contains?(:nylon)
91
+ end
92
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class YarnSkeinSubstitutionTest < Minitest::Test
6
+ def build_yarn(line:, yardage:, skein_weight:, fiber_content: nil)
7
+ YarnSkein::Yarn.new(
8
+ brand: "TestBrand",
9
+ line: line,
10
+ yardage: yardage,
11
+ skein_weight: skein_weight,
12
+ fiber_content: fiber_content
13
+ )
14
+ end
15
+
16
+ def target_yarn
17
+ @target_yarn ||= build_yarn(line: "Target", yardage: 210.yards, skein_weight: 100.grams)
18
+ end
19
+
20
+ # 210 yards/100g => worsted, yards_per_100g = 210
21
+
22
+ def close_match
23
+ @close_match ||= build_yarn(line: "Close", yardage: 200.yards, skein_weight: 100.grams)
24
+ end
25
+
26
+ def exact_match
27
+ @exact_match ||= build_yarn(line: "Exact", yardage: 210.yards, skein_weight: 100.grams)
28
+ end
29
+
30
+ def too_far
31
+ @too_far ||= build_yarn(line: "TooFar", yardage: 300.yards, skein_weight: 100.grams)
32
+ end
33
+
34
+ def different_category
35
+ @different_category ||= build_yarn(line: "Bulky", yardage: 120.yards, skein_weight: 100.grams)
36
+ end
37
+
38
+ def wool_yarn
39
+ @wool_yarn ||= build_yarn(
40
+ line: "Wool",
41
+ yardage: 200.yards,
42
+ skein_weight: 100.grams,
43
+ fiber_content: YarnSkein::FiberBlend.new(wool: 100)
44
+ )
45
+ end
46
+
47
+ def cotton_yarn
48
+ @cotton_yarn ||= build_yarn(
49
+ line: "Cotton",
50
+ yardage: 200.yards,
51
+ skein_weight: 100.grams,
52
+ fiber_content: YarnSkein::FiberBlend.new(cotton: 100)
53
+ )
54
+ end
55
+
56
+ # -----------------------------
57
+ # matches
58
+ # -----------------------------
59
+
60
+ def test_returns_yarns_matching_weight_category_and_grist
61
+ sub = YarnSkein::Substitution.new(target: target_yarn, catalog: [close_match, too_far])
62
+
63
+ assert_equal [close_match], sub.matches
64
+ end
65
+
66
+ def test_excludes_target_yarn_from_results
67
+ sub = YarnSkein::Substitution.new(target: target_yarn, catalog: [target_yarn, close_match])
68
+
69
+ assert_equal [close_match], sub.matches
70
+ end
71
+
72
+ def test_excludes_different_weight_category
73
+ sub = YarnSkein::Substitution.new(target: target_yarn, catalog: [different_category])
74
+
75
+ assert_empty sub.matches
76
+ end
77
+
78
+ def test_returns_empty_for_empty_catalog
79
+ sub = YarnSkein::Substitution.new(target: target_yarn, catalog: [])
80
+
81
+ assert_empty sub.matches
82
+ end
83
+
84
+ def test_returns_empty_when_no_matches
85
+ sub = YarnSkein::Substitution.new(target: target_yarn, catalog: [too_far, different_category])
86
+
87
+ assert_empty sub.matches
88
+ end
89
+
90
+ # -----------------------------
91
+ # tolerance
92
+ # -----------------------------
93
+
94
+ def test_configurable_tolerance
95
+ # close_match is 200/210 = ~0.952, within 15% but outside 2%
96
+ sub = YarnSkein::Substitution.new(target: target_yarn, catalog: [close_match])
97
+
98
+ assert_equal [close_match], sub.matches(tolerance: 0.15)
99
+ assert_empty sub.matches(tolerance: 0.02)
100
+ end
101
+
102
+ # -----------------------------
103
+ # fiber filter
104
+ # -----------------------------
105
+
106
+ def test_filters_by_fiber_content
107
+ sub = YarnSkein::Substitution.new(target: target_yarn, catalog: [wool_yarn, cotton_yarn])
108
+
109
+ assert_equal [wool_yarn], sub.matches(fiber: :wool)
110
+ end
111
+
112
+ def test_fiber_filter_excludes_yarns_without_fiber_content
113
+ yarn_no_fiber = build_yarn(line: "NoFiber", yardage: 200.yards, skein_weight: 100.grams)
114
+ sub = YarnSkein::Substitution.new(target: target_yarn, catalog: [yarn_no_fiber, wool_yarn])
115
+
116
+ assert_equal [wool_yarn], sub.matches(fiber: :wool)
117
+ end
118
+
119
+ def test_includes_exact_grist_match
120
+ sub = YarnSkein::Substitution.new(target: target_yarn, catalog: [exact_match])
121
+
122
+ assert_equal [exact_match], sub.matches
123
+ end
124
+ end