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 +4 -4
- data/CHANGELOG.md +17 -0
- data/Rakefile +5 -53
- data/lib/yarn_skein/catalog.rb +73 -0
- data/lib/yarn_skein/substitution.rb +30 -0
- data/lib/yarn_skein/version.rb +3 -1
- data/lib/yarn_skein/yardage_estimator.rb +71 -0
- data/lib/yarn_skein.rb +4 -0
- data/test/catalog_test.rb +182 -0
- data/test/fiber_blend_test.rb +92 -0
- data/test/substitution_test.rb +124 -0
- data/test/test_helper.rb +9 -0
- data/test/version_test.rb +10 -0
- data/test/weight_category_test.rb +37 -0
- data/test/yardage_estimator_test.rb +170 -0
- data/test/yarn_test.rb +229 -0
- metadata +45 -10
- data/.github/actions/ruby-setup/action.yml +0 -23
- data/.github/dependabot.yml +0 -8
- data/.github/workflows/ci.yml +0 -24
- data/.github/workflows/release.yml +0 -61
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2efd2c01dfa8a4654b1f110487399eb0111dba77daf030e7d3826d3514531a75
|
|
4
|
+
data.tar.gz: ea44d7874a49e18995a51e9ca09a04c787bf43db2f49a79281213f4679db14bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 "
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 :
|
|
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
|
data/lib/yarn_skein/version.rb
CHANGED
|
@@ -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
|