ukiryu 0.1.6 → 0.2.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ukiryu/cache.rb +6 -0
  3. data/lib/ukiryu/cache_registry.rb +64 -0
  4. data/lib/ukiryu/cli_commands/base_command.rb +6 -5
  5. data/lib/ukiryu/cli_commands/config_command.rb +7 -10
  6. data/lib/ukiryu/cli_commands/register_command.rb +27 -18
  7. data/lib/ukiryu/cli_commands/validate_command.rb +2 -2
  8. data/lib/ukiryu/command_builder.rb +83 -50
  9. data/lib/ukiryu/config.rb +13 -2
  10. data/lib/ukiryu/debug.rb +20 -9
  11. data/lib/ukiryu/definition/loader.rb +3 -3
  12. data/lib/ukiryu/errors.rb +37 -37
  13. data/lib/ukiryu/executable_locator.rb +40 -16
  14. data/lib/ukiryu/extractors/base_extractor.rb +2 -1
  15. data/lib/ukiryu/extractors/help_parser.rb +3 -0
  16. data/lib/ukiryu/logger.rb +51 -0
  17. data/lib/ukiryu/models/implementation_index.rb +2 -1
  18. data/lib/ukiryu/models/implementation_version.rb +18 -1
  19. data/lib/ukiryu/models/interface.rb +2 -1
  20. data/lib/ukiryu/models/run_environment.rb +0 -2
  21. data/lib/ukiryu/models/semantic_version.rb +174 -0
  22. data/lib/ukiryu/models/stage_metrics.rb +0 -1
  23. data/lib/ukiryu/register.rb +473 -232
  24. data/lib/ukiryu/shell/powershell.rb +209 -89
  25. data/lib/ukiryu/shell/sh.rb +4 -1
  26. data/lib/ukiryu/shell.rb +60 -2
  27. data/lib/ukiryu/tool/command_resolution.rb +2 -1
  28. data/lib/ukiryu/tool/executable_discovery.rb +14 -15
  29. data/lib/ukiryu/tool/loader.rb +543 -0
  30. data/lib/ukiryu/tool/version_detection.rb +1 -3
  31. data/lib/ukiryu/tool.rb +79 -87
  32. data/lib/ukiryu/tool_index.rb +127 -62
  33. data/lib/ukiryu/tools/base.rb +4 -2
  34. data/lib/ukiryu/type.rb +26 -15
  35. data/lib/ukiryu/version.rb +1 -1
  36. data/lib/ukiryu.rb +1 -1
  37. data/spec/fixtures/profiles/ghostscript_10.0.yaml +50 -0
  38. data/spec/fixtures/register/tools/ghostscript/default/10.0.yaml +6 -0
  39. data/spec/spec_helper.rb +10 -6
  40. data/spec/support/tool_helper.rb +2 -0
  41. data/spec/ukiryu/definition/loader_spec.rb +2 -2
  42. data/spec/ukiryu/executor_spec.rb +6 -3
  43. data/spec/ukiryu/models/execution_report_spec.rb +3 -2
  44. data/spec/ukiryu/models/semantic_version_spec.rb +284 -0
  45. data/spec/ukiryu/shell/powershell_integration_spec.rb +165 -0
  46. data/spec/ukiryu/shell/powershell_real_command_spec.rb +143 -0
  47. data/spec/ukiryu/shell/powershell_spec.rb +286 -51
  48. data/spec/ukiryu/tool/loader_spec.rb +148 -0
  49. data/spec/ukiryu/tool_index_spec.rb +110 -18
  50. data/spec/ukiryu/tools/ghostscript_spec.rb +242 -0
  51. data/spec/ukiryu/tools/imagemagick_spec.rb +2 -1
  52. data/spec/ukiryu/tools/inkscape_spec.rb +4 -2
  53. metadata +14 -2
  54. data/lib/ukiryu/register_auto_manager.rb +0 -342
data/lib/ukiryu/type.rb CHANGED
@@ -78,7 +78,10 @@ module Ukiryu
78
78
  raise Ukiryu::Errors::ValidationError, 'File path cannot be empty' if value.empty?
79
79
 
80
80
  # Check if file exists (only if require_existing is true)
81
- raise Ukiryu::Errors::ValidationError, "File not found: #{value}" if options[:require_existing] && !File.exist?(value)
81
+ if options[:require_existing] && !File.exist?(value)
82
+ raise Ukiryu::Errors::ValidationError,
83
+ "File not found: #{value}"
84
+ end
82
85
 
83
86
  value
84
87
  end
@@ -108,7 +111,10 @@ module Ukiryu
108
111
 
109
112
  if options[:range]
110
113
  min, max = options[:range]
111
- raise Ukiryu::Errors::ValidationError, "Integer #{integer} out of range [#{min}, #{max}]" if integer < min || integer > max
114
+ if integer < min || integer > max
115
+ raise Ukiryu::Errors::ValidationError,
116
+ "Integer #{integer} out of range [#{min}, #{max}]"
117
+ end
112
118
  end
113
119
 
114
120
  if options[:min] && integer < options[:min]
@@ -136,7 +142,10 @@ module Ukiryu
136
142
 
137
143
  if options[:range]
138
144
  min, max = options[:range]
139
- raise Ukiryu::Errors::ValidationError, "Float #{float} out of range [#{min}, #{max}]" if float < min || float > max
145
+ if float < min || float > max
146
+ raise Ukiryu::Errors::ValidationError,
147
+ "Float #{float} out of range [#{min}, #{max}]"
148
+ end
140
149
  end
141
150
 
142
151
  float
@@ -203,7 +212,10 @@ module Ukiryu
203
212
 
204
213
  # Validate hash type
205
214
  def validate_hash(value, options)
206
- raise Ukiryu::Errors::ValidationError, "Hash expected, got #{value.class}: #{value.inspect}" unless value.is_a?(Hash)
215
+ unless value.is_a?(Hash)
216
+ raise Ukiryu::Errors::ValidationError,
217
+ "Hash expected, got #{value.class}: #{value.inspect}"
218
+ end
207
219
 
208
220
  if options[:keys]
209
221
  unknown_keys = value.keys - options[:keys]
@@ -218,19 +230,17 @@ module Ukiryu
218
230
 
219
231
  # Validate array type
220
232
  def validate_array(value, options)
221
- # Debug logging for Ruby 4.0 CI
222
- if ENV['UKIRYU_DEBUG_EXECUTABLE']
223
- warn "[UKIRYU DEBUG Type.validate_array] value.class: #{value.class}"
224
- warn "[UKIRYU DEBUG Type.validate_array] value.inspect: #{value.inspect}"
225
- warn "[UKIRYU DEBUG Type.validate_array] options: #{options.inspect}"
226
- end
233
+ # Debug logging for executable discovery
234
+ Logger.debug("Type.validate_array value.class: #{value.class}", category: :executable)
235
+ Logger.debug("Type.validate_array value.inspect: #{value.inspect}", category: :executable)
236
+ Logger.debug("Type.validate_array options: #{options.inspect}", category: :executable)
227
237
 
228
238
  array = value.is_a?(Array) ? value : [value]
229
239
 
230
- if ENV['UKIRYU_DEBUG_EXECUTABLE']
231
- warn "[UKIRYU DEBUG Type.validate_array] after conversion, array.class: #{array.class}"
232
- warn "[UKIRYU DEBUG Type.validate_array] after conversion, array.inspect: #{array.inspect}"
233
- end
240
+ Logger.debug("Type.validate_array after conversion, array.class: #{array.class}",
241
+ category: :executable)
242
+ Logger.debug("Type.validate_array after conversion, array.inspect: #{array.inspect}",
243
+ category: :executable)
234
244
 
235
245
  if options[:min] && array.size < options[:min]
236
246
  raise Ukiryu::Errors::ValidationError,
@@ -259,7 +269,8 @@ module Ukiryu
259
269
  # Validate element type if specified
260
270
  array = array.map { |v| validate(v, options[:of], options) } if options[:of]
261
271
 
262
- warn "[UKIRYU DEBUG Type.validate_array] returning array.inspect: #{array.inspect}" if ENV['UKIRYU_DEBUG_EXECUTABLE']
272
+ Logger.debug("Type.validate_array returning array.inspect: #{array.inspect}",
273
+ category: :executable)
263
274
 
264
275
  array
265
276
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ukiryu
4
- VERSION = '0.1.6'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/ukiryu.rb CHANGED
@@ -46,11 +46,11 @@ module Ukiryu
46
46
  # Internal Tool implementation classes - lazy load with autoload
47
47
  autoload :CommandBuilder, 'ukiryu/command_builder'
48
48
  autoload :Cache, 'ukiryu/cache'
49
+ autoload :CacheRegistry, 'ukiryu/cache_registry'
49
50
  autoload :ExecutableLocator, 'ukiryu/executable_locator'
50
51
  autoload :VersionDetector, 'ukiryu/version_detector'
51
52
  autoload :ToolIndex, 'ukiryu/tool_index'
52
53
  autoload :ManPageParser, 'ukiryu/man_page_parser'
53
- autoload :RegisterAutoManager, 'ukiryu/register_auto_manager'
54
54
 
55
55
  # Model classes - lazy load with autoload (these are directly under Ukiryu namespace)
56
56
  autoload :ToolMetadata, 'ukiryu/models/tool_metadata'
@@ -7,6 +7,8 @@ homepage: https://www.ghostscript.com/
7
7
  version: '10.0'
8
8
  aliases:
9
9
  - gs
10
+ - gswin64c
11
+ - gswin32c
10
12
  version_detection:
11
13
  command: "--version"
12
14
  pattern: "(\\d+\\.\\d+)"
@@ -18,6 +20,7 @@ search_paths:
18
20
  - "/usr/bin/gs"
19
21
  windows:
20
22
  - "C:/Program Files/gs/gs*/bin/gswin64c.exe"
23
+ - "C:/Program Files/gs/gs*/bin/gswin32c.exe"
21
24
  profiles:
22
25
  - name: unix
23
26
  display_name: Ghostscript on Unix
@@ -67,3 +70,50 @@ profiles:
67
70
  default: true
68
71
  assignment_delimiter: none
69
72
  name: convert
73
+ - name: windows
74
+ display_name: Ghostscript on Windows
75
+ platforms:
76
+ - windows
77
+ shells:
78
+ - powershell
79
+ - cmd
80
+ version: ">= 9.0"
81
+ option_style: single_dash_equals
82
+ commands:
83
+ - description: Convert PostScript or PDF to other formats
84
+ usage: gswin64c [options] input-file
85
+ arguments:
86
+ - name: inputs
87
+ type: file
88
+ variadic: true
89
+ min: 1
90
+ position: last
91
+ description: Input file(s) to process
92
+ options:
93
+ - name: device
94
+ type: string
95
+ cli: "-sDEVICE"
96
+ description: Output device (e.g., png16m, jpeg, pdfwrite)
97
+ assignment_delimiter: equals
98
+ - name: output
99
+ type: file
100
+ cli: "-sOutputFile"
101
+ description: Output file path
102
+ assignment_delimiter: equals
103
+ - name: resolution
104
+ type: string
105
+ cli: "-r"
106
+ description: Resolution (e.g., 150, 300, or 600x600)
107
+ assignment_delimiter: auto
108
+ flags:
109
+ - name: quiet
110
+ cli: "-q"
111
+ description: Quiet mode
112
+ default: false
113
+ assignment_delimiter: none
114
+ - name: batch
115
+ cli: "-dBATCH"
116
+ description: Batch mode
117
+ default: true
118
+ assignment_delimiter: none
119
+ name: convert
@@ -6,10 +6,15 @@ homepage: https://www.ghostscript.com/
6
6
  version: '1.0'
7
7
  aliases:
8
8
  - gs
9
+ - gswin64c
10
+ - gswin32c
9
11
  version_detection:
10
12
  command: "--version"
11
13
  pattern: "(\\d+\\.\\d+)"
12
14
  search_paths:
15
+ windows:
16
+ - "C:/Program Files/gs/gs*/bin/gswin64c.exe"
17
+ - "C:/Program Files/gs/gs*/bin/gswin32c.exe"
13
18
  implements: ghostscript/1.0
14
19
  profiles:
15
20
  - name: unix
@@ -21,6 +26,7 @@ profiles:
21
26
  - zsh
22
27
  - sh
23
28
  - fish
29
+ - powershell
24
30
  option_style: single_dash_space
25
31
  executable_name: ghostscript
26
32
  commands:
data/spec/spec_helper.rb CHANGED
@@ -8,7 +8,7 @@ require 'timeout'
8
8
  require 'ukiryu'
9
9
 
10
10
  # Require all support files
11
- Dir[File.join(__dir__, "support", "**", "*.rb")].each { |f| require f }
11
+ Dir[File.join(__dir__, 'support', '**', '*.rb')].sort.each { |f| require f }
12
12
 
13
13
  RSpec.configure do |config|
14
14
  # Enable flags like --only-failures and --next-failure
@@ -40,12 +40,12 @@ RSpec.configure do |config|
40
40
  # Set up test register path if UKIRYU_REGISTER is not already set
41
41
  unless ENV['UKIRYU_REGISTER']
42
42
  test_register = File.expand_path('fixtures/register', __dir__)
43
- if Dir.exist?(test_register)
44
- ENV['UKIRYU_REGISTER'] = test_register
45
- Ukiryu::Register.default_register_path = test_register
46
- end
43
+ ENV['UKIRYU_REGISTER'] = test_register if Dir.exist?(test_register)
47
44
  end
48
45
 
46
+ # Reset the default register to pick up the test register
47
+ Ukiryu::Register.reset_default
48
+
49
49
  # Reset ToolIndex to pick up the new register path
50
50
  Ukiryu::ToolIndex.reset
51
51
 
@@ -59,11 +59,15 @@ RSpec.configure do |config|
59
59
  # Reset singleton state before each test to prevent pollution
60
60
  config.before(:each) do
61
61
  Ukiryu::Config.reset!
62
- Ukiryu::Register.reset_version_cache
62
+ Ukiryu::Register.reset_default
63
63
  Ukiryu::ToolIndex.reset
64
64
  Ukiryu::Tool.clear_cache
65
65
  Ukiryu::Runtime.instance.reset!
66
66
  Ukiryu::Tools::Generator.clear_cache
67
+ Ukiryu::Shell.reset
68
+
69
+ # Clean up test shell environment variable that may be left by other tests
70
+ ENV.delete('UKIRYU_TEST_SHELL')
67
71
 
68
72
  # Remove generated tool classes from Tools namespace
69
73
  Ukiryu::Tools.constants.each do |const|
@@ -15,6 +15,8 @@ module ToolHelper
15
15
  def tool_available?(name)
16
16
  tool = get_tool(name)
17
17
  tool.available?
18
+ rescue Ukiryu::Errors::ToolNotFoundError
19
+ false
18
20
  end
19
21
 
20
22
  # Get tool version if available
@@ -174,8 +174,8 @@ RSpec.describe Ukiryu::Definition::Loader do
174
174
  end
175
175
 
176
176
  describe '.profile_cache' do
177
- it 'returns a hash' do
178
- expect(described_class.profile_cache).to be_a(Hash)
177
+ it 'returns a bounded cache' do
178
+ expect(described_class.profile_cache).to be_a(Ukiryu::Cache)
179
179
  end
180
180
 
181
181
  it 'returns same cache on multiple calls' do
@@ -152,7 +152,8 @@ RSpec.describe Ukiryu::Executor do
152
152
  # Use platform-appropriate command that exits with specific code
153
153
  result = if Ukiryu::Platform.windows?
154
154
  # Windows: PowerShell supports exit codes
155
- executor.execute('powershell', ['-Command', 'exit 42'], allow_failure: true, shell: :powershell, timeout: 30)
155
+ executor.execute('powershell', ['-Command', 'exit 42'], allow_failure: true, shell: :powershell,
156
+ timeout: 30)
156
157
  else
157
158
  executor.execute('sh', ['-c', 'exit 42'], allow_failure: true, shell: shell_symbol, timeout: 30)
158
159
  end
@@ -215,7 +216,8 @@ RSpec.describe Ukiryu::Executor do
215
216
  # Use echo as a cross-platform command that always works
216
217
  result = if Ukiryu::Platform.windows?
217
218
  # On Windows with PowerShell, use echo command via PowerShell
218
- executor.execute('powershell', ['-Command', 'echo test'], allow_failure: true, shell: :powershell, timeout: 90)
219
+ executor.execute('powershell', ['-Command', 'echo test'], allow_failure: true, shell: :powershell,
220
+ timeout: 90)
219
221
  else
220
222
  executor.execute('echo', ['test'], allow_failure: true, shell: shell_symbol, timeout: 90)
221
223
  end
@@ -415,7 +417,8 @@ RSpec.describe Ukiryu::Executor do
415
417
  end
416
418
 
417
419
  it 'handles commands with special characters in arguments' do
418
- result = executor.execute('echo', ['hello "quoted" test\'s'], allow_failure: true, shell: shell_symbol, timeout: 90)
420
+ result = executor.execute('echo', ['hello "quoted" test\'s'], allow_failure: true, shell: shell_symbol,
421
+ timeout: 90)
419
422
 
420
423
  expect(result.stdout.strip).to include('hello')
421
424
  end
@@ -77,9 +77,10 @@ RSpec.describe Ukiryu::Models::ExecutionReport do
77
77
  # In Docker/CI, may be 0 or an actual value
78
78
  expect(stage.memory_after).to be_a(Integer)
79
79
  expect(stage.memory_after).to be >= 0
80
- # If memory detection works, memory_after should be >= memory_before
81
- expect(stage.memory_after).to be >= stage.memory_before if stage.memory_before > 0
80
+ # Memory delta is calculated (may be negative due to GC)
82
81
  expect(stage.memory_delta).to be_a(Integer)
82
+ # Verify delta calculation is correct
83
+ expect(stage.memory_delta).to eq(stage.memory_after - stage.memory_before)
83
84
  end
84
85
 
85
86
  it 'marks the stage as successful by default' do
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'ukiryu/models/semantic_version'
5
+
6
+ RSpec.describe Ukiryu::Models::SemanticVersion do
7
+ describe '.parse' do
8
+ it 'parses simple version' do
9
+ expect(described_class.parse('10.0')).to eq([10, 0])
10
+ end
11
+
12
+ it 'parses three-part version' do
13
+ expect(described_class.parse('1.2.3')).to eq([1, 2, 3])
14
+ end
15
+
16
+ it 'parses single number version' do
17
+ expect(described_class.parse('5')).to eq([5])
18
+ end
19
+
20
+ it 'handles nil' do
21
+ expect(described_class.parse(nil)).to eq([0])
22
+ end
23
+
24
+ it 'handles empty string' do
25
+ expect(described_class.parse('')).to eq([0])
26
+ end
27
+
28
+ it 'handles non-numeric parts as 0' do
29
+ expect(described_class.parse('1.alpha.3')).to eq([1, 0, 3])
30
+ end
31
+ end
32
+
33
+ describe '.compare' do
34
+ it 'returns 1 when first version is greater' do
35
+ expect(described_class.compare('10.0', '9.5')).to eq(1)
36
+ end
37
+
38
+ it 'returns -1 when first version is lesser' do
39
+ expect(described_class.compare('9.5', '10.0')).to eq(-1)
40
+ end
41
+
42
+ it 'returns 0 when versions are equal' do
43
+ expect(described_class.compare('10.0', '10.0')).to eq(0)
44
+ end
45
+
46
+ it 'handles different segment counts' do
47
+ expect(described_class.compare('10.0.1', '10.0')).to eq(1)
48
+ end
49
+
50
+ it 'handles single vs multi-part versions' do
51
+ expect(described_class.compare('10', '9.5')).to eq(1)
52
+ end
53
+ end
54
+
55
+ describe '#initialize' do
56
+ it 'stores original string' do
57
+ version = described_class.new('10.0')
58
+ expect(version.original).to eq('10.0')
59
+ end
60
+
61
+ it 'parses segments' do
62
+ version = described_class.new('10.0.5')
63
+ expect(version.segments).to eq([10, 0, 5])
64
+ end
65
+
66
+ it 'handles integer input' do
67
+ version = described_class.new(10)
68
+ expect(version.segments).to eq([10])
69
+ end
70
+
71
+ it 'handles nil input' do
72
+ version = described_class.new(nil)
73
+ expect(version.segments).to eq([0])
74
+ end
75
+ end
76
+
77
+ describe '#<=>' do
78
+ it 'compares 10.0 > 9.5 (the critical bug case)' do
79
+ v1 = described_class.new('10.0')
80
+ v2 = described_class.new('9.5')
81
+ expect(v1 <=> v2).to eq(1)
82
+ end
83
+
84
+ it 'compares 9.5 < 10.0' do
85
+ v1 = described_class.new('9.5')
86
+ v2 = described_class.new('10.0')
87
+ expect(v1 <=> v2).to eq(-1)
88
+ end
89
+
90
+ it 'compares equal versions as 0' do
91
+ v1 = described_class.new('10.0')
92
+ v2 = described_class.new('10.0')
93
+ expect(v1 <=> v2).to eq(0)
94
+ end
95
+
96
+ it 'compares 1.10.0 > 1.9.9' do
97
+ v1 = described_class.new('1.10.0')
98
+ v2 = described_class.new('1.9.9')
99
+ expect(v1 <=> v2).to eq(1)
100
+ end
101
+
102
+ it 'compares with string' do
103
+ version = described_class.new('10.0')
104
+ expect(version <=> '9.5').to eq(1)
105
+ end
106
+
107
+ it 'compares with integer' do
108
+ version = described_class.new('10.0')
109
+ expect(version <=> 9).to eq(1)
110
+ end
111
+
112
+ it 'returns nil for non-comparable' do
113
+ version = described_class.new('10.0')
114
+ expect(version <=> Object.new).to be_nil
115
+ end
116
+ end
117
+
118
+ describe '#==' do
119
+ it 'returns true for equal versions' do
120
+ v1 = described_class.new('10.0')
121
+ v2 = described_class.new('10.0')
122
+ expect(v1 == v2).to be true
123
+ end
124
+
125
+ it 'returns true for equivalent versions with different segment counts' do
126
+ v1 = described_class.new('10.0')
127
+ v2 = described_class.new('10.0.0')
128
+ expect(v1 == v2).to be true
129
+ end
130
+
131
+ it 'returns false for different versions' do
132
+ v1 = described_class.new('10.0')
133
+ v2 = described_class.new('9.5')
134
+ expect(v1 == v2).to be false
135
+ end
136
+
137
+ it 'compares with string' do
138
+ version = described_class.new('10.0')
139
+ expect(version == '10.0').to be true
140
+ end
141
+ end
142
+
143
+ describe '#>' do
144
+ it 'returns true when greater' do
145
+ expect(described_class.new('10.0')).to be > described_class.new('9.5')
146
+ end
147
+
148
+ it 'returns false when equal' do
149
+ expect(described_class.new('10.0')).not_to be > described_class.new('10.0')
150
+ end
151
+
152
+ it 'returns false when lesser' do
153
+ expect(described_class.new('9.5')).not_to be > described_class.new('10.0')
154
+ end
155
+ end
156
+
157
+ describe '#<' do
158
+ it 'returns true when lesser' do
159
+ expect(described_class.new('9.5')).to be < described_class.new('10.0')
160
+ end
161
+
162
+ it 'returns false when equal' do
163
+ expect(described_class.new('10.0')).not_to be < described_class.new('10.0')
164
+ end
165
+ end
166
+
167
+ describe '#>=' do
168
+ it 'returns true when greater' do
169
+ expect(described_class.new('10.0')).to be >= described_class.new('9.5')
170
+ end
171
+
172
+ it 'returns true when equal' do
173
+ expect(described_class.new('10.0')).to be >= described_class.new('10.0')
174
+ end
175
+
176
+ it 'returns false when lesser' do
177
+ expect(described_class.new('9.5')).not_to be >= described_class.new('10.0')
178
+ end
179
+ end
180
+
181
+ describe '#<=' do
182
+ it 'returns true when lesser' do
183
+ expect(described_class.new('9.5')).to be <= described_class.new('10.0')
184
+ end
185
+
186
+ it 'returns true when equal' do
187
+ expect(described_class.new('10.0')).to be <= described_class.new('10.0')
188
+ end
189
+ end
190
+
191
+ describe '#to_s' do
192
+ it 'returns version string' do
193
+ version = described_class.new('10.0.5')
194
+ expect(version.to_s).to eq('10.0.5')
195
+ end
196
+ end
197
+
198
+ describe '#inspect' do
199
+ it 'returns inspect string' do
200
+ version = described_class.new('10.0')
201
+ expect(version.inspect).to eq('#<Ukiryu::Models::SemanticVersion 10.0>')
202
+ end
203
+ end
204
+
205
+ describe '#hash and #eql?' do
206
+ it 'can be used as hash key' do
207
+ v1 = described_class.new('10.0')
208
+ v2 = described_class.new('10.0')
209
+ v3 = described_class.new('9.5')
210
+
211
+ hash = { v1 => 'first', v3 => 'second' }
212
+
213
+ expect(hash[v2]).to eq('first')
214
+ expect(hash[v3]).to eq('second')
215
+ end
216
+
217
+ it 'eql? returns true for equal versions' do
218
+ v1 = described_class.new('10.0')
219
+ v2 = described_class.new('10.0')
220
+ expect(v1.eql?(v2)).to be true
221
+ end
222
+
223
+ it 'eql? returns false for different versions' do
224
+ v1 = described_class.new('10.0')
225
+ v2 = described_class.new('9.5')
226
+ expect(v1.eql?(v2)).to be false
227
+ end
228
+ end
229
+
230
+ describe 'Comparable integration' do
231
+ it 'works with Array#max' do
232
+ versions = [
233
+ described_class.new('9.5'),
234
+ described_class.new('10.0'),
235
+ described_class.new('8.0')
236
+ ]
237
+ expect(versions.max.to_s).to eq('10.0')
238
+ end
239
+
240
+ it 'works with Array#sort' do
241
+ versions = [
242
+ described_class.new('9.5'),
243
+ described_class.new('10.0'),
244
+ described_class.new('8.0')
245
+ ]
246
+ expect(versions.sort.map(&:to_s)).to eq(['8.0', '9.5', '10.0'])
247
+ end
248
+
249
+ it 'works with Array#min' do
250
+ versions = [
251
+ described_class.new('9.5'),
252
+ described_class.new('10.0'),
253
+ described_class.new('8.0')
254
+ ]
255
+ expect(versions.min.to_s).to eq('8.0')
256
+ end
257
+ end
258
+
259
+ describe 'real-world Ghostscript case' do
260
+ it 'correctly selects 10.0 over 9.5' do
261
+ v10 = described_class.new('10.0')
262
+ v95 = described_class.new('9.5')
263
+
264
+ # This was the bug: alphabetical sort would select 9.5 over 10.0
265
+ # because '9' > '1' in ASCII
266
+ expect([v95, v10].max).to eq(v10)
267
+ end
268
+
269
+ it 'demonstrates the bug with string comparison' do
270
+ # Show why string comparison is wrong
271
+ filenames = ['9.5.yaml', '10.0.yaml']
272
+
273
+ # WRONG: alphabetical max gives '9.5.yaml' (because '9' > '1')
274
+ alphabetical_max = filenames.max
275
+ expect(alphabetical_max).to eq('9.5.yaml')
276
+
277
+ # CORRECT: semantic version comparison gives '10.0.yaml'
278
+ semantic_max = filenames.max_by do |f|
279
+ described_class.new(File.basename(f, '.yaml'))
280
+ end
281
+ expect(semantic_max).to eq('10.0.yaml')
282
+ end
283
+ end
284
+ end