yarn_skein 0.2.0 → 0.4.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: de030bc1badf27891aebbba5deb39a73ab035fbe4be31842f4e9d2bb8105ecdb
4
- data.tar.gz: 9ae3781907723962ea50c3b25a3f98705c321f251f981a6a558b3a81218ed442
3
+ metadata.gz: 6e58123240286568c5799c6ee0d7ed8572de554f44c4e2cbf653e178be29a181
4
+ data.tar.gz: bd9aba63f86efb708f7156575d7136d91cd660f05bc857b5a0cb50b8087586b7
5
5
  SHA512:
6
- metadata.gz: 5c4bbcb7d360334e2f79d733f4d53d2371414f2c6e73bc48b67d1a3e027e2c48a1e822d56fbc424ef6c42dfacbbcd471f00ab55497baac641ea510f4564748a0
7
- data.tar.gz: cc0e8ac28ad96f6977471b3fca483989ca8b0e2142040dce66632714c0c3b022d972ec363f395af645f4143a41757357bf48ef362833a399a183d08a0093b708
6
+ metadata.gz: 7a13f3cde5def54d9bdd85026f2d143a9e3d0e9ca4b76023573839c8fc6f6d1b224580e62eb19907b6559a08391103470efb71fb7e6dba428d1364f64d4ea165
7
+ data.tar.gz: 398c7cfaa30a61beb4839fda0bc96737023b03df676b1fb85cae9e8854e4d7119e2f32be1de5fe2a86d402ea365450a0547b8749938395f68cb18644b61d5567
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-03-28
4
+
5
+ ### Added
6
+ - `YarnSkein::ColorworkEstimator` for estimating per-color yarn yardage in stranded and intarsia colorwork
7
+ - Supports float overhead calculation for stranded colorwork (default 20%)
8
+ - Per-color breakdown with optional skein calculations when yarn is provided
9
+ - Configurable safety margin (default 10%) consistent with `YardageEstimator`
10
+
11
+ ## [0.3.0] - 2026-03-26
12
+
13
+ ### Added
14
+ - `YarnSkein::Catalog` for loading yarn data from YAML seed files with filtering by brand, weight category, and fiber content
15
+ - `YarnSkein::YardageEstimator` for estimating total yarn yardage from gauge and dimensions
16
+ - Rectangular piece estimation via `for_rectangle(width:, height:)`
17
+ - Stitch/row count estimation via `for_piece(stitches:, rows:)`
18
+ - Configurable safety margin (default 10%) for waste and joining
19
+ - Added `fiber_gauge` as a runtime dependency
3
20
 
4
21
  ## [0.2.0] - 2026-03-21
5
22
 
@@ -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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YarnSkein
4
+ # Estimates per-color yarn yardage for colorwork techniques.
5
+ #
6
+ # Supports stranded (Fair Isle) and intarsia colorwork. Stranded colorwork
7
+ # adds a float overhead factor because unused colors are carried across the
8
+ # back of the fabric. Intarsia uses separate yarn sections with no floats.
9
+ #
10
+ # @example Stranded colorwork
11
+ # estimator = YarnSkein::ColorworkEstimator.new(
12
+ # gauge: gauge,
13
+ # technique: :stranded
14
+ # )
15
+ # estimator.estimate(
16
+ # width: 40.inches, height: 24.inches,
17
+ # colors: { main: 0.60, contrast: 0.40 },
18
+ # yarn: yarn
19
+ # )
20
+ #
21
+ # @example Intarsia colorwork
22
+ # estimator = YarnSkein::ColorworkEstimator.new(
23
+ # gauge: gauge,
24
+ # technique: :intarsia
25
+ # )
26
+ # estimator.estimate(
27
+ # width: 40.inches, height: 24.inches,
28
+ # colors: { left: 0.50, right: 0.50 },
29
+ # yarn: yarn
30
+ # )
31
+ class ColorworkEstimator
32
+ TECHNIQUES = %i[stranded intarsia].freeze
33
+
34
+ # Default float overhead for stranded colorwork (20%).
35
+ DEFAULT_FLOAT_OVERHEAD = 0.20
36
+
37
+ # Default safety margin (10%), consistent with YardageEstimator.
38
+ DEFAULT_MARGIN = 0.10
39
+
40
+ # @return [FiberGauge::Gauge]
41
+ attr_reader :gauge
42
+
43
+ # @return [Symbol] :stranded or :intarsia
44
+ attr_reader :technique
45
+
46
+ # @param gauge [FiberGauge::Gauge] gauge swatch data
47
+ # @param technique [Symbol] :stranded or :intarsia
48
+ def initialize(gauge:, technique:)
49
+ unless TECHNIQUES.include?(technique)
50
+ raise ArgumentError, "technique must be one of: #{TECHNIQUES.join(", ")}"
51
+ end
52
+
53
+ @gauge = gauge
54
+ @technique = technique
55
+ end
56
+
57
+ # Estimates per-color yardage for a colorwork piece.
58
+ #
59
+ # @param width [FiberUnits::Length] finished width
60
+ # @param height [FiberUnits::Length] finished height
61
+ # @param colors [Hash<Symbol, Float>] color name => proportion (must sum to 1.0)
62
+ # @param yarn [YarnSkein::Yarn, nil] yarn for skein calculations
63
+ # @param margin [Float] safety margin (default 10%)
64
+ # @param float_overhead [Float] extra yarn for floats in stranded work (default 20%)
65
+ # @return [Hash] per-color breakdown and total
66
+ def estimate(width:, height:, colors:, yarn: nil, margin: DEFAULT_MARGIN, float_overhead: DEFAULT_FLOAT_OVERHEAD)
67
+ validate_colors!(colors)
68
+
69
+ base_yardage = base_yardage_for(width, height, margin: 0.0)
70
+ result = {}
71
+ total_yards = 0.0
72
+
73
+ colors.each do |color_name, proportion|
74
+ color_yards = base_yardage * proportion
75
+ color_yards *= (1.0 + float_overhead) if technique == :stranded
76
+ color_yards *= (1.0 + margin)
77
+ total_yards += color_yards
78
+
79
+ entry = {yardage: color_yards.yards}
80
+ entry[:skeins] = yarn.skeins_required(color_yards.yards) if yarn
81
+ result[color_name] = entry
82
+ end
83
+
84
+ result[:total] = {yardage: total_yards.yards}
85
+ result
86
+ end
87
+
88
+ private
89
+
90
+ # Compute raw yardage (in float yards) for a rectangle with no margin.
91
+ def base_yardage_for(width, height, margin:)
92
+ stitches = gauge.required_stitches(width)
93
+ rows = gauge.required_rows(height)
94
+ total_stitches = stitches.value * rows.value
95
+
96
+ total_stitches * yards_per_stitch
97
+ end
98
+
99
+ # Yards of yarn consumed per stitch, using the geometric U-loop model.
100
+ def yards_per_stitch
101
+ stitch_width_in = 1.0 / gauge.spi
102
+ stitch_height_in = 1.0 / gauge.rpi
103
+ (stitch_width_in + (2.0 * stitch_height_in)) / 36.0
104
+ end
105
+
106
+ def validate_colors!(colors)
107
+ raise ArgumentError, "colors must be a non-empty Hash" unless colors.is_a?(Hash) && !colors.empty?
108
+
109
+ total = colors.values.sum
110
+ unless (total - 1.0).abs < 0.001
111
+ raise ArgumentError, "color proportions must sum to 1.0 (got #{total})"
112
+ end
113
+
114
+ colors.each do |name, proportion|
115
+ unless proportion.is_a?(Numeric) && proportion > 0
116
+ raise ArgumentError, "proportion for #{name} must be a positive number"
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -3,6 +3,6 @@
3
3
  # :nocov:
4
4
  module YarnSkein
5
5
  # Current gem version.
6
- VERSION = "0.2.0"
6
+ VERSION = "0.4.0"
7
7
  end
8
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,11 +1,15 @@
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"
8
9
  require_relative "yarn_skein/substitution"
10
+ require_relative "yarn_skein/yardage_estimator"
11
+ require_relative "yarn_skein/colorwork_estimator"
12
+ require_relative "yarn_skein/catalog"
9
13
 
10
14
  # Domain objects for describing yarn skeins, fiber blends, and weight classes.
11
15
  #
@@ -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,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class YarnSkeinColorworkEstimatorTest < Minitest::Test
6
+ def gauge
7
+ FiberGauge::Gauge.new(
8
+ stitches: 18.stitches,
9
+ rows: 24.rows,
10
+ width: 4.inches,
11
+ height: 4.inches
12
+ )
13
+ end
14
+
15
+ def yarn
16
+ YarnSkein::Yarn.new(
17
+ brand: "Malabrigo",
18
+ line: "Rios",
19
+ yardage: 210.yards,
20
+ skein_weight: 100.grams
21
+ )
22
+ end
23
+
24
+ def stranded_estimator
25
+ YarnSkein::ColorworkEstimator.new(gauge: gauge, technique: :stranded)
26
+ end
27
+
28
+ def intarsia_estimator
29
+ YarnSkein::ColorworkEstimator.new(gauge: gauge, technique: :intarsia)
30
+ end
31
+
32
+ # ----------------------------
33
+ # initialization
34
+ # ----------------------------
35
+
36
+ def test_rejects_invalid_technique
37
+ assert_raises(ArgumentError) do
38
+ YarnSkein::ColorworkEstimator.new(gauge: gauge, technique: :mosaic)
39
+ end
40
+ end
41
+
42
+ def test_accepts_stranded_technique
43
+ est = YarnSkein::ColorworkEstimator.new(gauge: gauge, technique: :stranded)
44
+ assert_equal :stranded, est.technique
45
+ end
46
+
47
+ def test_accepts_intarsia_technique
48
+ est = YarnSkein::ColorworkEstimator.new(gauge: gauge, technique: :intarsia)
49
+ assert_equal :intarsia, est.technique
50
+ end
51
+
52
+ # ----------------------------
53
+ # color validation
54
+ # ----------------------------
55
+
56
+ def test_rejects_empty_colors
57
+ assert_raises(ArgumentError) do
58
+ stranded_estimator.estimate(width: 20.inches, height: 20.inches, colors: {})
59
+ end
60
+ end
61
+
62
+ def test_rejects_non_hash_colors
63
+ assert_raises(ArgumentError) do
64
+ stranded_estimator.estimate(width: 20.inches, height: 20.inches, colors: [0.5, 0.5])
65
+ end
66
+ end
67
+
68
+ def test_rejects_proportions_not_summing_to_one
69
+ assert_raises(ArgumentError) do
70
+ stranded_estimator.estimate(
71
+ width: 20.inches, height: 20.inches,
72
+ colors: {main: 0.60, contrast: 0.30}
73
+ )
74
+ end
75
+ end
76
+
77
+ def test_rejects_zero_proportion
78
+ assert_raises(ArgumentError) do
79
+ stranded_estimator.estimate(
80
+ width: 20.inches, height: 20.inches,
81
+ colors: {main: 1.0, contrast: 0.0}
82
+ )
83
+ end
84
+ end
85
+
86
+ # ----------------------------
87
+ # stranded estimation
88
+ # ----------------------------
89
+
90
+ def test_stranded_returns_per_color_yardage
91
+ result = stranded_estimator.estimate(
92
+ width: 40.inches, height: 24.inches,
93
+ colors: {main: 0.60, contrast: 0.40}
94
+ )
95
+
96
+ assert result.key?(:main)
97
+ assert result.key?(:contrast)
98
+ assert result.key?(:total)
99
+ assert_instance_of FiberUnits::Length, result[:main][:yardage]
100
+ assert_instance_of FiberUnits::Length, result[:contrast][:yardage]
101
+ assert_instance_of FiberUnits::Length, result[:total][:yardage]
102
+ end
103
+
104
+ def test_stranded_includes_float_overhead
105
+ stranded = stranded_estimator.estimate(
106
+ width: 40.inches, height: 24.inches,
107
+ colors: {main: 0.60, contrast: 0.40},
108
+ margin: 0.0, float_overhead: 0.20
109
+ )
110
+
111
+ intarsia = intarsia_estimator.estimate(
112
+ width: 40.inches, height: 24.inches,
113
+ colors: {main: 0.60, contrast: 0.40},
114
+ margin: 0.0
115
+ )
116
+
117
+ # Stranded total should be 20% more than intarsia (due to float overhead)
118
+ stranded_total = stranded[:total][:yardage].to(:yards).value
119
+ intarsia_total = intarsia[:total][:yardage].to(:yards).value
120
+
121
+ assert_in_delta intarsia_total * 1.20, stranded_total, 0.01
122
+ end
123
+
124
+ def test_stranded_custom_float_overhead
125
+ low_overhead = stranded_estimator.estimate(
126
+ width: 40.inches, height: 24.inches,
127
+ colors: {main: 0.60, contrast: 0.40},
128
+ margin: 0.0, float_overhead: 0.15
129
+ )
130
+
131
+ high_overhead = stranded_estimator.estimate(
132
+ width: 40.inches, height: 24.inches,
133
+ colors: {main: 0.60, contrast: 0.40},
134
+ margin: 0.0, float_overhead: 0.25
135
+ )
136
+
137
+ assert high_overhead[:total][:yardage].to(:yards).value >
138
+ low_overhead[:total][:yardage].to(:yards).value
139
+ end
140
+
141
+ def test_stranded_proportions_affect_distribution
142
+ result = stranded_estimator.estimate(
143
+ width: 40.inches, height: 24.inches,
144
+ colors: {main: 0.70, contrast: 0.30},
145
+ margin: 0.0
146
+ )
147
+
148
+ main_yards = result[:main][:yardage].to(:yards).value
149
+ contrast_yards = result[:contrast][:yardage].to(:yards).value
150
+
151
+ assert_in_delta 7.0 / 3.0, main_yards / contrast_yards, 0.01
152
+ end
153
+
154
+ # ----------------------------
155
+ # intarsia estimation
156
+ # ----------------------------
157
+
158
+ def test_intarsia_no_float_overhead
159
+ intarsia = intarsia_estimator.estimate(
160
+ width: 40.inches, height: 24.inches,
161
+ colors: {left: 0.50, right: 0.50},
162
+ margin: 0.0
163
+ )
164
+
165
+ # For intarsia, each half should get half the base yardage
166
+ left_yards = intarsia[:left][:yardage].to(:yards).value
167
+ right_yards = intarsia[:right][:yardage].to(:yards).value
168
+
169
+ assert_in_delta left_yards, right_yards, 0.01
170
+ end
171
+
172
+ def test_intarsia_total_matches_base_yardage
173
+ intarsia = intarsia_estimator.estimate(
174
+ width: 20.inches, height: 30.inches,
175
+ colors: {left: 0.50, right: 0.50},
176
+ margin: 0.0
177
+ )
178
+
179
+ # Total intarsia yardage should match a plain YardageEstimator (no margin)
180
+ base = YarnSkein::YardageEstimator.new(gauge: gauge, yarn: yarn)
181
+ base_result = base.for_rectangle(width: 20.inches, height: 30.inches, margin: 0.0)
182
+
183
+ assert_in_delta(
184
+ base_result[:yardage].to(:yards).value,
185
+ intarsia[:total][:yardage].to(:yards).value,
186
+ 0.01
187
+ )
188
+ end
189
+
190
+ # ----------------------------
191
+ # skein calculations
192
+ # ----------------------------
193
+
194
+ def test_includes_skeins_when_yarn_provided
195
+ result = stranded_estimator.estimate(
196
+ width: 40.inches, height: 24.inches,
197
+ colors: {main: 0.60, contrast: 0.40},
198
+ yarn: yarn
199
+ )
200
+
201
+ assert result[:main].key?(:skeins)
202
+ assert result[:contrast].key?(:skeins)
203
+ assert_instance_of Integer, result[:main][:skeins]
204
+ assert_instance_of Integer, result[:contrast][:skeins]
205
+ end
206
+
207
+ def test_omits_skeins_when_no_yarn
208
+ result = stranded_estimator.estimate(
209
+ width: 40.inches, height: 24.inches,
210
+ colors: {main: 0.60, contrast: 0.40}
211
+ )
212
+
213
+ refute result[:main].key?(:skeins)
214
+ refute result[:contrast].key?(:skeins)
215
+ end
216
+
217
+ # ----------------------------
218
+ # margin
219
+ # ----------------------------
220
+
221
+ def test_default_margin_applied
222
+ no_margin = intarsia_estimator.estimate(
223
+ width: 20.inches, height: 20.inches,
224
+ colors: {main: 1.0},
225
+ margin: 0.0
226
+ )
227
+
228
+ with_margin = intarsia_estimator.estimate(
229
+ width: 20.inches, height: 20.inches,
230
+ colors: {main: 1.0}
231
+ )
232
+
233
+ assert_in_delta(
234
+ no_margin[:main][:yardage].to(:yards).value * 1.10,
235
+ with_margin[:main][:yardage].to(:yards).value,
236
+ 0.01
237
+ )
238
+ end
239
+
240
+ # ----------------------------
241
+ # multi-color
242
+ # ----------------------------
243
+
244
+ def test_three_color_stranded
245
+ result = stranded_estimator.estimate(
246
+ width: 40.inches, height: 24.inches,
247
+ colors: {main: 0.50, contrast_a: 0.30, contrast_b: 0.20},
248
+ margin: 0.0
249
+ )
250
+
251
+ assert result.key?(:main)
252
+ assert result.key?(:contrast_a)
253
+ assert result.key?(:contrast_b)
254
+ assert result.key?(:total)
255
+
256
+ total = result[:main][:yardage].to(:yards).value +
257
+ result[:contrast_a][:yardage].to(:yards).value +
258
+ result[:contrast_b][:yardage].to(:yards).value
259
+
260
+ assert_in_delta total, result[:total][:yardage].to(:yards).value, 0.01
261
+ end
262
+
263
+ # ----------------------------
264
+ # realistic scenario
265
+ # ----------------------------
266
+
267
+ def test_fair_isle_yoke_estimate_is_reasonable
268
+ # A Fair Isle yoke ~20" wide, 12" tall in worsted weight
269
+ result = stranded_estimator.estimate(
270
+ width: 20.inches, height: 12.inches,
271
+ colors: {main: 0.60, contrast: 0.40},
272
+ yarn: yarn
273
+ )
274
+
275
+ main_yards = result[:main][:yardage].to(:yards).value
276
+ contrast_yards = result[:contrast][:yardage].to(:yards).value
277
+ total_yards = result[:total][:yardage].to(:yards).value
278
+
279
+ assert total_yards > 50, "total #{total_yards} seems too low"
280
+ assert total_yards < 500, "total #{total_yards} seems too high"
281
+ assert main_yards > contrast_yards, "main color should use more yarn"
282
+ end
283
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class YarnSkeinYardageEstimatorTest < Minitest::Test
6
+ def gauge
7
+ FiberGauge::Gauge.new(
8
+ stitches: 18.stitches,
9
+ rows: 24.rows,
10
+ width: 4.inches,
11
+ height: 4.inches
12
+ )
13
+ end
14
+
15
+ def yarn
16
+ YarnSkein::Yarn.new(
17
+ brand: "Malabrigo",
18
+ line: "Rios",
19
+ yardage: 210.yards,
20
+ skein_weight: 100.grams
21
+ )
22
+ end
23
+
24
+ def estimator
25
+ YarnSkein::YardageEstimator.new(gauge: gauge, yarn: yarn)
26
+ end
27
+
28
+ # -----------------------------
29
+ # yards_per_stitch
30
+ # -----------------------------
31
+
32
+ def test_yards_per_stitch_returns_positive_value
33
+ assert estimator.yards_per_stitch > 0
34
+ end
35
+
36
+ def test_yards_per_stitch_uses_geometric_model
37
+ spi = gauge.spi
38
+ rpi = gauge.rpi
39
+ expected = (1.0 / spi + 2.0 / rpi) / 36.0
40
+
41
+ assert_in_delta expected, estimator.yards_per_stitch, 0.0001
42
+ end
43
+
44
+ # -----------------------------
45
+ # for_piece
46
+ # -----------------------------
47
+
48
+ def test_for_piece_returns_yardage_and_skeins
49
+ result = estimator.for_piece(stitches: 100.stitches, rows: 100.rows)
50
+
51
+ assert result.key?(:yardage)
52
+ assert result.key?(:skeins)
53
+ assert_instance_of FiberUnits::Length, result[:yardage]
54
+ assert_instance_of Integer, result[:skeins]
55
+ end
56
+
57
+ def test_for_piece_yardage_is_positive
58
+ result = estimator.for_piece(stitches: 100.stitches, rows: 100.rows)
59
+
60
+ assert result[:yardage].value > 0
61
+ end
62
+
63
+ def test_for_piece_includes_default_margin
64
+ no_margin = estimator.for_piece(stitches: 100.stitches, rows: 100.rows, margin: 0.0)
65
+ with_margin = estimator.for_piece(stitches: 100.stitches, rows: 100.rows)
66
+
67
+ assert_in_delta(
68
+ no_margin[:yardage].to(:yards).value * 1.10,
69
+ with_margin[:yardage].to(:yards).value,
70
+ 0.01
71
+ )
72
+ end
73
+
74
+ def test_for_piece_custom_margin
75
+ no_margin = estimator.for_piece(stitches: 100.stitches, rows: 100.rows, margin: 0.0)
76
+ with_margin = estimator.for_piece(stitches: 100.stitches, rows: 100.rows, margin: 0.20)
77
+
78
+ assert_in_delta(
79
+ no_margin[:yardage].to(:yards).value * 1.20,
80
+ with_margin[:yardage].to(:yards).value,
81
+ 0.01
82
+ )
83
+ end
84
+
85
+ def test_for_piece_zero_margin
86
+ result = estimator.for_piece(stitches: 100.stitches, rows: 100.rows, margin: 0.0)
87
+
88
+ total_ops = 100 * 100
89
+ expected_yards = total_ops * estimator.yards_per_stitch
90
+
91
+ assert_in_delta expected_yards, result[:yardage].to(:yards).value, 0.01
92
+ end
93
+
94
+ def test_for_piece_skeins_ceil_to_whole_number
95
+ result = estimator.for_piece(stitches: 50.stitches, rows: 50.rows, margin: 0.0)
96
+
97
+ raw_yards = 50 * 50 * estimator.yards_per_stitch
98
+ expected_skeins = (raw_yards / 210.0).ceil
99
+
100
+ assert_equal expected_skeins, result[:skeins]
101
+ end
102
+
103
+ # -----------------------------
104
+ # for_rectangle
105
+ # -----------------------------
106
+
107
+ def test_for_rectangle_returns_yardage_and_skeins
108
+ result = estimator.for_rectangle(width: 20.inches, height: 30.inches)
109
+
110
+ assert result.key?(:yardage)
111
+ assert result.key?(:skeins)
112
+ end
113
+
114
+ def test_for_rectangle_uses_gauge_to_compute_stitches
115
+ rect_result = estimator.for_rectangle(width: 20.inches, height: 30.inches, margin: 0.0)
116
+
117
+ stitches = gauge.required_stitches(20.inches)
118
+ rows = gauge.required_rows(30.inches)
119
+ piece_result = estimator.for_piece(stitches: stitches, rows: rows, margin: 0.0)
120
+
121
+ assert_in_delta(
122
+ piece_result[:yardage].to(:yards).value,
123
+ rect_result[:yardage].to(:yards).value,
124
+ 0.01
125
+ )
126
+ end
127
+
128
+ def test_for_rectangle_with_custom_margin
129
+ no_margin = estimator.for_rectangle(width: 20.inches, height: 30.inches, margin: 0.0)
130
+ with_margin = estimator.for_rectangle(width: 20.inches, height: 30.inches, margin: 0.15)
131
+
132
+ assert_in_delta(
133
+ no_margin[:yardage].to(:yards).value * 1.15,
134
+ with_margin[:yardage].to(:yards).value,
135
+ 0.01
136
+ )
137
+ end
138
+
139
+ # -----------------------------
140
+ # realistic scenarios
141
+ # -----------------------------
142
+
143
+ def test_scarf_estimate_is_reasonable
144
+ # A scarf ~8" wide, 60" long in worsted weight
145
+ result = estimator.for_rectangle(width: 8.inches, height: 60.inches)
146
+
147
+ yards = result[:yardage].to(:yards).value
148
+ # A worsted scarf typically uses 200-400 yards
149
+ assert yards > 100, "yardage #{yards} seems too low for a scarf"
150
+ assert yards < 600, "yardage #{yards} seems too high for a scarf"
151
+ end
152
+
153
+ def test_blanket_estimate_is_reasonable
154
+ # A throw blanket ~50" x 60" in worsted weight
155
+ result = estimator.for_rectangle(width: 50.inches, height: 60.inches)
156
+
157
+ yards = result[:yardage].to(:yards).value
158
+ # A worsted throw typically uses 1500-3500 yards
159
+ assert yards > 1000, "yardage #{yards} seems too low for a blanket"
160
+ assert yards < 5000, "yardage #{yards} seems too high for a blanket"
161
+ end
162
+
163
+ def test_larger_piece_needs_more_yarn
164
+ small = estimator.for_rectangle(width: 10.inches, height: 10.inches)
165
+ large = estimator.for_rectangle(width: 20.inches, height: 20.inches)
166
+
167
+ assert large[:yardage].to(:yards).value > small[:yardage].to(:yards).value
168
+ assert large[:skeins] >= small[:skeins]
169
+ end
170
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yarn_skein
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Meagan Waller
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: fiber_gauge
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: minitest
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -51,25 +65,31 @@ files:
51
65
  - README.md
52
66
  - Rakefile
53
67
  - lib/yarn_skein.rb
68
+ - lib/yarn_skein/catalog.rb
69
+ - lib/yarn_skein/colorwork_estimator.rb
54
70
  - lib/yarn_skein/fiber_blend.rb
55
71
  - lib/yarn_skein/substitution.rb
56
72
  - lib/yarn_skein/version.rb
57
73
  - lib/yarn_skein/weight_category.rb
74
+ - lib/yarn_skein/yardage_estimator.rb
58
75
  - lib/yarn_skein/yarn.rb
59
76
  - sig/yarn_skein.rbs
77
+ - test/catalog_test.rb
78
+ - test/colorwork_estimator_test.rb
60
79
  - test/fiber_blend_test.rb
61
80
  - test/substitution_test.rb
62
81
  - test/test_helper.rb
63
82
  - test/version_test.rb
64
83
  - test/weight_category_test.rb
84
+ - test/yardage_estimator_test.rb
65
85
  - test/yarn_test.rb
66
- homepage: https://github.com/meaganewaller/yarn_skein
86
+ homepage: https://github.com/meaganewaller/craftos/tree/main/gems/yarn_skein
67
87
  licenses:
68
88
  - MIT
69
89
  metadata:
70
- homepage_uri: https://github.com/meaganewaller/yarn_skein
71
- source_code_uri: https://github.com/meaganewaller/yarn_skein
72
- changelog_uri: https://github.com/meaganewaller/yarn_skein/blob/main/CHANGELOG.md
90
+ homepage_uri: https://github.com/meaganewaller/craftos/tree/main/gems/yarn_skein
91
+ source_code_uri: https://github.com/meaganewaller/craftos/tree/main/gems/yarn_skein
92
+ changelog_uri: https://github.com/meaganewaller/craftos/tree/main/gems/yarn_skein/CHANGELOG.md
73
93
  rdoc_options: []
74
94
  require_paths:
75
95
  - lib