dugway 1.1.0 → 1.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.gitignore +1 -0
  4. data/lib/dugway/application.rb +5 -3
  5. data/lib/dugway/assets/big_cartel_logo.svg +4 -0
  6. data/lib/dugway/cli/build.rb +7 -1
  7. data/lib/dugway/cli/server.rb +2 -2
  8. data/lib/dugway/cli/templates/source/settings.json +8 -0
  9. data/lib/dugway/cli/validate.rb +9 -2
  10. data/lib/dugway/controller.rb +5 -1
  11. data/lib/dugway/liquid/drops/account_drop.rb +4 -0
  12. data/lib/dugway/liquid/drops/features_drop.rb +144 -0
  13. data/lib/dugway/liquid/drops/theme_drop.rb +23 -0
  14. data/lib/dugway/liquid/drops/translations_drop.rb +122 -0
  15. data/lib/dugway/liquifier.rb +44 -8
  16. data/lib/dugway/store.rb +7 -2
  17. data/lib/dugway/theme.rb +107 -10
  18. data/lib/dugway/version.rb +1 -1
  19. data/lib/dugway.rb +31 -1
  20. data/locales/storefront.de.yml +79 -0
  21. data/locales/storefront.en-CA.yml +79 -0
  22. data/locales/storefront.en-GB.yml +79 -0
  23. data/locales/storefront.en-US.yml +79 -0
  24. data/locales/storefront.es-ES.yml +79 -0
  25. data/locales/storefront.es-MX.yml +79 -0
  26. data/locales/storefront.fr-CA.yml +79 -0
  27. data/locales/storefront.fr-FR.yml +79 -0
  28. data/locales/storefront.id.yml +79 -0
  29. data/locales/storefront.it.yml +79 -0
  30. data/locales/storefront.ja.yml +79 -0
  31. data/locales/storefront.ko.yml +79 -0
  32. data/locales/storefront.nl.yml +79 -0
  33. data/locales/storefront.pl.yml +79 -0
  34. data/locales/storefront.pt-BR.yml +79 -0
  35. data/locales/storefront.pt-PT.yml +79 -0
  36. data/locales/storefront.ro.yml +79 -0
  37. data/locales/storefront.sv.yml +79 -0
  38. data/locales/storefront.tr.yml +79 -0
  39. data/locales/storefront.zh-CN.yml +79 -0
  40. data/locales/storefront.zh-TW.yml +79 -0
  41. data/log/dugway.log +1 -0
  42. data/spec/features/page_rendering_spec.rb +4 -4
  43. data/spec/fixtures/theme/layout.html +2 -0
  44. data/spec/fixtures/theme/settings.json +6 -0
  45. data/spec/spec_helper.rb +4 -0
  46. data/spec/units/dugway/liquid/drops/features_drop_spec.rb +182 -0
  47. data/spec/units/dugway/liquid/drops/theme_drop_spec.rb +45 -0
  48. data/spec/units/dugway/liquid/drops/translations_drop_spec.rb +292 -0
  49. data/spec/units/dugway/store_spec.rb +37 -0
  50. data/spec/units/dugway/theme_spec.rb +297 -1
  51. metadata +32 -2
@@ -0,0 +1,292 @@
1
+ require 'spec_helper'
2
+
3
+ describe Dugway::Drops::TranslationsDrop do
4
+ let(:settings) { {} }
5
+ let(:definitions) { {} }
6
+ let(:drop) { Dugway::Drops::TranslationsDrop.new(settings, definitions) }
7
+
8
+ before(:all) do
9
+ # Store original I18n settings to restore them later
10
+ @original_i18n_load_path = I18n.load_path.dup
11
+ @original_i18n_available_locales = I18n.config.available_locales.dup
12
+ @original_i18n_default_locale = I18n.default_locale
13
+ @original_i18n_locale = I18n.locale
14
+
15
+ # Create a unique temporary directory for theme-specific locale files for this test run
16
+ @theme_parent_dir = File.expand_path("../../../../tmp/translations_drop_spec_#{Time.now.to_f}", __FILE__)
17
+ @theme_source_dir = File.join(@theme_parent_dir, 'source') # For Dugway.source_dir mock
18
+ @theme_locales_dir = File.join(@theme_parent_dir, '_dugway_locales') # Theme locale files go here
19
+
20
+ FileUtils.mkdir_p(@theme_source_dir)
21
+ FileUtils.mkdir_p(@theme_locales_dir)
22
+
23
+ # Write theme-specific test files. These will override or supplement gem locales.
24
+ # Using 'navigation.cart' as a key that exists in actual gem locales (e.g., storefront.en-US.yml)
25
+ # and 'farewell' as a theme-only key.
26
+ # We use 'en-US' for the theme file to directly override the gem's 'en-US' translations.
27
+ File.write(File.join(@theme_locales_dir, 'storefront.en-US.yml'), <<~YAML)
28
+ en-US:
29
+ storefront:
30
+ navigation:
31
+ cart: "Theme Cart" # Override gem's "Cart"
32
+ farewell: "Goodbye Theme" # Theme-only
33
+ YAML
34
+ File.write(File.join(@theme_locales_dir, 'storefront.fr.yml'), <<~YAML)
35
+ fr:
36
+ storefront:
37
+ navigation:
38
+ cart: "Panier du Thème"
39
+ YAML
40
+
41
+ # Force load gem locales first (mimicking initial gem load)
42
+ # The main lib/dugway.rb (required via spec_helper) should have already configured
43
+ # I18n.load_path and I18n.config.available_locales with the gem's actual files.
44
+ # We are capturing this initial state and then augmenting it for theme-specific test files.
45
+
46
+ @theme_locale_files = Dir.glob(File.join(@theme_locales_dir, 'storefront.*.yml'))
47
+
48
+ # Augment the load path: original gem paths + new theme paths.
49
+ # Theme files are added last so their definitions override gem's if keys conflict.
50
+ I18n.load_path = (@original_i18n_load_path + @theme_locale_files).uniq
51
+
52
+ # Augment available locales: original gem locales + new theme locales.
53
+ theme_locale_codes = @theme_locale_files.map do |file|
54
+ name = File.basename(file, '.yml')
55
+ locale_code = name.match(/^storefront\.(.+)$/)&.captures&.first
56
+ locale_code&.to_sym
57
+ end.compact
58
+
59
+ current_available_locales = (@original_i18n_available_locales.to_a + theme_locale_codes).uniq
60
+
61
+ if current_available_locales.any? { |loc| loc.to_s.start_with?('en-') } && !current_available_locales.include?(:en)
62
+ current_available_locales << :en
63
+ end
64
+
65
+ I18n.config.available_locales = current_available_locales
66
+
67
+ # Force a clean slate for translations before reloading.
68
+ # This ensures that the load path order is strictly respected for overrides.
69
+ I18n.backend.send(:init_translations) if I18n.backend.respond_to?(:init_translations, true)
70
+
71
+ # Reload the backend to process all files in the (now augmented) I18n.load_path
72
+ # and to recognize all locales in I18n.config.available_locales.
73
+ I18n.backend.reload!
74
+
75
+ # Verify that :'en-US' (which should come from the gem's original setup) is recognized.
76
+ # If this fails, it means :'en-US' was not in @original_i18n_available_locales or was lost.
77
+ unless I18n.config.available_locales.include?(:'en-US') && I18n.backend.available_locales.include?(:'en-US')
78
+ error_message = "I18n Setup Issue: :en-US is not available after setup.\n"
79
+ error_message += "I18n.config.available_locales: #{I18n.config.available_locales.inspect}\n"
80
+ error_message += "I18n.backend.available_locales: #{I18n.backend.available_locales.inspect}\n"
81
+ error_message += "I18n.load_path: #{I18n.load_path.inspect}\n"
82
+ error_message += "@original_i18n_available_locales: #{@original_i18n_available_locales.inspect}\n"
83
+ error_message += "@original_i18n_load_path: #{@original_i18n_load_path.inspect}"
84
+ raise error_message
85
+ end
86
+
87
+ I18n.default_locale = :'en-US'
88
+ I18n.locale = :'en-US'
89
+ end
90
+
91
+ after(:all) do
92
+ # Clean up temporary theme directory
93
+ FileUtils.rm_rf(@theme_parent_dir)
94
+
95
+ # Restore original I18n settings
96
+ I18n.load_path = @original_i18n_load_path
97
+ I18n.config.available_locales = @original_i18n_available_locales
98
+ I18n.default_locale = @original_i18n_default_locale
99
+ I18n.locale = @original_i18n_locale
100
+ I18n.backend.reload!
101
+ end
102
+ let(:logger_double) { double('Logger', warn: nil) }
103
+
104
+ before(:each) do
105
+ allow(Dugway).to receive(:gem_root).and_return(File.expand_path('../../../../../', __FILE__))
106
+ allow(Dugway).to receive(:source_dir).and_return(@theme_source_dir)
107
+
108
+ allow(Dugway).to receive(:logger).and_return(logger_double)
109
+ end
110
+
111
+ describe 'Automatic Mode (Default)' do
112
+ context "with default language ('en-US', falling back to 'en' for theme)" do
113
+ # These tests rely on I18n.locale being 'en-US' (set in before(:all))
114
+ # The theme provides 'storefront.en.yml'
115
+ it "returns theme override for a key also in gem locales" do
116
+ # Gem 'en-US' (locales/storefront.en-US.yml) has storefront.navigation.cart: "Cart"
117
+ # Theme 'en' (temporary file) has storefront.navigation.cart: "Theme Cart"
118
+ expect(drop['navigation.cart']).to eq('Theme Cart')
119
+ end
120
+
121
+ it "returns theme-only translation if present" do
122
+ # 'farewell' is only in the temporary theme 'en' file
123
+ expect(drop['farewell']).to eq('Goodbye Theme')
124
+ end
125
+
126
+ it "returns gem translation if not in theme" do
127
+ # 'navigation.all_products' is in gem 'en-US.yml', not in our test theme 'en.yml'
128
+ # The value from locales/storefront.en-US.yml is "All products"
129
+ expect(drop['navigation.all_products']).to eq('All products')
130
+ end
131
+
132
+ it "returns missing translation message for unknown key" do
133
+ expect(drop['unknown.key']).to eq('[MISSING TRANSLATION: unknown.key]')
134
+ end
135
+
136
+ it "logs a warning for missing translation with the correct locale" do
137
+ expect(Dugway.logger).to receive(:warn).with("Missing translation: key='unknown.key.again' locale='en-US' mode='automatic'")
138
+ drop['unknown.key.again']
139
+ end
140
+ end
141
+
142
+ context "with specified locale ('fr')" do
143
+ let(:settings) { { translation_locale: 'fr' } }
144
+
145
+ it "returns translation for the specified language from theme" do
146
+ # Theme 'fr' (temporary file) has storefront.navigation.cart: "Panier du Thème"
147
+ expect(drop['navigation.cart']).to eq('Panier du Thème')
148
+ end
149
+
150
+ it "returns missing translation message if key not found in specified language (and not in fallbacks)" do
151
+ # 'farewell' is not in theme 'fr.yml' or any 'fr' gem locale
152
+ expect(drop['farewell']).to eq('[MISSING TRANSLATION: farewell]')
153
+ end
154
+
155
+ it "returns gem translation if key not found in specified theme language but exists in gem's version of that language" do
156
+ # Assuming 'locales/storefront.fr-FR.yml' (loaded by gem) has 'navigation.all': 'Tous'
157
+ # and our temporary theme 'fr.yml' does not have 'navigation.all'
158
+ # This test depends on the content of the actual gem's fr-FR file.
159
+ # For a more robust test, ensure 'locales/storefront.fr-FR.yml' has 'navigation: { all: "Tous" }'
160
+ # Or, if 'fr-FR' is not a primary test target, this test could be more generic or skipped.
161
+ # For now, let's assume 'navigation.all' exists in a loaded 'fr' variant from the gem.
162
+ # If 'locales/storefront.fr-FR.yml' exists and is loaded:
163
+ # expect(drop['navigation.all']).to eq(I18n.t('storefront.navigation.all', locale: :fr))
164
+ # If not, it will be missing. Let's test for missing if not in theme 'fr'.
165
+ expect(drop['navigation.all_products']).to eq('[MISSING TRANSLATION: navigation.all_products]')
166
+ end
167
+
168
+ it "logs a warning with the correct locale" do
169
+ expect(Dugway.logger).to receive(:warn).with("Missing translation: key='farewell.again' locale='fr' mode='automatic'")
170
+ drop['farewell.again']
171
+ end
172
+ end
173
+
174
+ context "with specified non-existent locale ('xx')" do
175
+ let(:settings) { { translation_locale: 'xx' } }
176
+
177
+ it "returns missing translation message for a known key" do
178
+ expect(drop['navigation.cart']).to eq('[MISSING TRANSLATION: navigation.cart]')
179
+ end
180
+ end
181
+ end
182
+
183
+ describe 'Manual Mode' do
184
+ let(:settings) do
185
+ {
186
+ translation_mode: 'manual',
187
+ greeting_tr_text: 'Hello Manual',
188
+ 'nested_message_tr_text' => 'Manual Nested',
189
+ farewell_tr_text: '',
190
+ explicit_nil_tr_text: nil
191
+ }
192
+ end
193
+
194
+ it "returns translation from manual setting (symbol key)" do
195
+ expect(drop['greeting']).to eq('Hello Manual')
196
+ end
197
+
198
+ it "returns translation from manual setting (string key)" do
199
+ # Note: The drop converts the lookup key 'nested.message' to :nested_message_tr_text
200
+ # but the settings hash might have string keys from JSON parsing.
201
+ # The drop implementation checks both symbol and string keys in the settings hash.
202
+ expect(drop['nested.message']).to eq('Manual Nested')
203
+ end
204
+
205
+ it "falls back to automatic translation if manual setting is blank and allow_blank is false (default)" do
206
+ # No definition provided, so allow_blank defaults to false
207
+ # 'farewell' exists in theme 'en' locale (storefront.en.yml created by test)
208
+ # I18n.locale is 'en-US'. The drop will use this locale.
209
+ expect(I18n).to receive(:t).with('storefront.farewell', locale: 'en-US', default: nil).and_call_original
210
+ expect(drop['farewell']).to eq('Goodbye Theme')
211
+ end
212
+
213
+ it "returns missing translation message if manual setting is explicitly nil" do
214
+ # If the key exists in manual settings but the value is nil, treat it as missing.
215
+ expect(drop['explicit_nil']).to eq('[MISSING TRANSLATION: explicit_nil]')
216
+ end
217
+
218
+ it "returns missing translation message if manual setting is not present" do
219
+ expect(drop['unknown.key']).to eq('[MISSING TRANSLATION: unknown.key]')
220
+ end
221
+
222
+ it "logs a warning for missing translation (manual mode)" do
223
+ # Expect a call to the stubbed logger
224
+ expect(Dugway.logger).to receive(:warn).with("Missing translation: key='unknown.key.manual' mode='manual'")
225
+ drop['unknown.key.manual']
226
+ end
227
+
228
+ it "does not use I18n backend if a valid manual setting is found" do
229
+ expect(I18n).not_to receive(:t)
230
+ expect(drop['greeting']).to eq('Hello Manual') # Uses manual setting
231
+ end
232
+
233
+ it "uses I18n backend if manual setting is blank and allow_blank is false" do
234
+ expect(I18n).to receive(:t).with('storefront.farewell', locale: 'en-US', default: nil).and_call_original
235
+ drop['farewell'] # Trigger lookup for blank, disallowed value
236
+ end
237
+
238
+ it "uses I18n backend if manual setting key is not found" do
239
+ expect(I18n).to receive(:t).with('storefront.unknown.key', locale: 'en-US', default: nil).and_call_original
240
+ drop['unknown.key'] # Trigger lookup for missing key
241
+ end
242
+
243
+ context "with allow_blank definitions" do
244
+ let(:definitions) do
245
+ {
246
+ 'options' => [
247
+ { 'variable' => 'allow_blank_true_tr_text', 'allow_blank' => true },
248
+ { 'variable' => 'allow_blank_false_tr_text', 'allow_blank' => false },
249
+ { 'variable' => 'allow_blank_missing_tr_text' }, # allow_blank defaults to false
250
+ { 'variable' => 'allow_blank_non_blank_val_tr_text', 'allow_blank' => false }
251
+ ]
252
+ }
253
+ end
254
+ let(:settings) do
255
+ {
256
+ translation_mode: 'manual',
257
+ allow_blank_true_tr_text: '',
258
+ allow_blank_false_tr_text: '',
259
+ allow_blank_missing_tr_text: '',
260
+ allow_blank_non_blank_val_tr_text: 'Not Blank'
261
+ }
262
+ end
263
+
264
+ it "returns blank string when allow_blank is true" do
265
+ expect(drop['allow_blank_true']).to eq('')
266
+ end
267
+
268
+ it "falls back to automatic translation when allow_blank is false" do
269
+ # Assuming 'allow_blank_false' doesn't exist in automatic translations
270
+ expect(I18n).to receive(:t).with('storefront.allow_blank_false', locale: 'en-US', default: nil).and_return(nil)
271
+ expect(drop['allow_blank_false']).to eq('[MISSING TRANSLATION: allow_blank_false]')
272
+ end
273
+
274
+ it "falls back to automatic translation when allow_blank is missing (defaults to false)" do
275
+ # Assuming 'allow_blank_missing' doesn't exist in automatic translations
276
+ expect(I18n).to receive(:t).with('storefront.allow_blank_missing', locale: 'en-US', default: nil).and_return(nil)
277
+ expect(drop['allow_blank_missing']).to eq('[MISSING TRANSLATION: allow_blank_missing]')
278
+ end
279
+
280
+ it "returns non-blank value even when allow_blank is false" do
281
+ expect(drop['allow_blank_non_blank_val']).to eq('Not Blank')
282
+ end
283
+ end
284
+ end
285
+
286
+ describe '#derive_manual_setting_key' do
287
+ # Test the private method indirectly via its usage or directly if needed
288
+ it 'converts dot notation to underscore symbol notation' do
289
+ expect(drop.send(:derive_manual_setting_key, 'some.key.name')).to eq(:some_key_name_tr_text)
290
+ end
291
+ end
292
+ end
@@ -189,4 +189,41 @@ describe Dugway::Store do
189
189
  store.locale.should == 'en-US'
190
190
  end
191
191
  end
192
+
193
+ describe "#website" do
194
+ context "when website is provided in store_options" do
195
+ let(:store_options) { { website: 'https://my-custom-override.com' } }
196
+ let(:store) { Dugway::Store.new('dugway', store_options) }
197
+
198
+ it "returns the website from store_options" do
199
+ store.website.should == 'https://my-custom-override.com'
200
+ end
201
+ end
202
+
203
+ context "when website is not in store_options but in account data" do
204
+ let(:store) { Dugway::Store.new('dugway', {}) } # No override
205
+
206
+ before do
207
+ # Mock the account data to include a website
208
+ allow(store).to receive(:account).and_return({ 'website' => 'https://api-provided-site.com' })
209
+ end
210
+
211
+ it "returns the website from account data" do
212
+ store.website.should == 'https://api-provided-site.com'
213
+ end
214
+ end
215
+
216
+ context "when website is in neither store_options nor account data" do
217
+ let(:store) { Dugway::Store.new('dugway', {}) } # No override
218
+
219
+ before do
220
+ # Mock the account data to NOT include a website
221
+ allow(store).to receive(:account).and_return({})
222
+ end
223
+
224
+ it "returns nil" do
225
+ store.website.should be_nil
226
+ end
227
+ end
228
+ end
192
229
  end
@@ -387,7 +387,7 @@ describe Dugway::Theme do
387
387
  {'variable' => 'button_text_color'},
388
388
  {'variable' => 'button_hover_background_color'}
389
389
  ],
390
- 'options' => [{ 'variable' => 'show_search', 'label' => 'Show search' }]
390
+ 'options' => [{ 'variable' => 'show_search', 'section' => 'global_navigation', 'label' => 'Show search' }]
391
391
  } }
392
392
  end
393
393
 
@@ -417,6 +417,7 @@ describe Dugway::Theme do
417
417
  settings = theme.settings.merge({
418
418
  'options' => [{
419
419
  'variable' => 'show_search',
420
+ 'section' => 'global_navigation',
420
421
  'label' => 'Show search',
421
422
  'description' => 'Show search in header'
422
423
  }]
@@ -452,7 +453,302 @@ describe Dugway::Theme do
452
453
  theme.valid?.should be(false)
453
454
  theme.errors.first.should match(/Missing required color settings:/)
454
455
  end
456
+
457
+ it "validates feature flag format and values" do
458
+ settings = theme.settings.merge({
459
+ 'options' => [{
460
+ 'variable' => 'show_foo',
461
+ 'section' => 'general',
462
+ 'description' => 'Show foo thing',
463
+ 'requires' => [
464
+ 'feature:', # invalid: empty
465
+ 'feature:foo_flag gt visible', # invalid operator
466
+ 'feature:foo_flag eq invalid_value', # invalid value
467
+ 'feature:foo_flag neq visible', # valid
468
+ 'feature:foo_flag eq visible' # valid
469
+ ]
470
+ }]
471
+ })
472
+ allow(theme).to receive(:settings).and_return(settings)
473
+
474
+ theme.valid?.should be(false)
475
+ theme.errors.should include("Option 'show_foo' has invalid feature flag format")
476
+ theme.errors.should include("Option 'show_foo' has invalid operator 'gt'. Feature flags can only use `eq` or `neq`.")
477
+ theme.errors.should include("Option 'show_foo' has invalid comparison value 'invalid_value'. Feature flags can only check for `visible`.")
478
+
479
+ end
480
+ end
481
+
482
+ describe "when validating option sections" do
483
+ let(:valid_sections_string) { Dugway::Theme::VALID_SECTIONS.join(', ') }
484
+ let(:base_settings) do
485
+ {
486
+ 'name' => 'Test Theme',
487
+ 'version' => '1.2.3',
488
+ 'colors' => required_colors.map { |color| {'variable' => color} },
489
+ 'options' => []
490
+ }
491
+ end
492
+
493
+ # Define required_colors here if not already available in this scope
494
+ let(:required_colors) {[
495
+ 'background_color',
496
+ 'primary_text_color',
497
+ 'link_text_color',
498
+ 'link_hover_color',
499
+ 'button_background_color',
500
+ 'button_text_color',
501
+ 'button_hover_background_color'
502
+ ]}
503
+
504
+ # Fix for the failing tests - define constant if needed
505
+ before(:each) do
506
+ unless defined?(Dugway::Theme::REQUIRED_FILES)
507
+ stub_const("Dugway::Theme::REQUIRED_FILES", [
508
+ "cart.html", "contact.html", "home.html", "layout.html", "maintenance.html",
509
+ "product.html", "products.html", "screenshot.jpg", "settings.json", "theme.css", "theme.js"
510
+ ])
511
+ end
512
+ end
513
+
514
+ it "passes with a valid section" do
515
+ settings = base_settings.merge({
516
+ 'options' => [{
517
+ 'variable' => 'valid_option',
518
+ 'description' => 'Valid option',
519
+ 'section' => 'homepage'
520
+ }]
521
+ })
522
+ # Stub settings
523
+ allow(theme).to receive(:settings).and_return(settings)
524
+
525
+ # Stub ALL required files first
526
+ Dugway::Theme::REQUIRED_FILES.each do |file|
527
+ allow(theme).to receive(:read_source_file).with(file).and_return("some content")
528
+ end
529
+
530
+ # Then override the specific stub for layout.html
531
+ allow(theme).to receive(:read_source_file).with('layout.html')
532
+ .and_return('<body data-bc-page-type="home"><div data-bc-hook="header"></div><div data-bc-hook="footer"></div></body>')
533
+
534
+ # Stub color validation
535
+ allow(theme).to receive(:validate_required_color_settings).and_return(true)
536
+
537
+ expect(Kernel).not_to receive(:warn)
538
+ theme.valid?.should be(true)
539
+ end
540
+
541
+ it "warns appropriately for multiple options with missing or invalid sections" do
542
+ settings = base_settings.merge({
543
+ 'options' => [
544
+ { 'variable' => 'option_1_valid', 'description' => 'Valid', 'section' => 'homepage' },
545
+ { 'variable' => 'option_2_missing', 'description' => 'Missing section' }, # Missing section
546
+ { 'variable' => 'option_3_invalid', 'description' => 'Invalid section', 'section' => 'bad_section' }, # Invalid section
547
+ { 'variable' => 'option_4_valid', 'description' => 'Valid again', 'section' => 'general' },
548
+ { 'variable' => 'option_5_missing', 'description' => 'Missing section again' }, # Missing section again
549
+ { 'description' => 'Option 6 missing variable and section' }, # Missing variable and section
550
+ { 'variable' => 'option_7_invalid', 'description' => 'Invalid section again', 'section' => 'another_bad_one' } # Invalid section again
551
+ ]
552
+ })
553
+ # Stub settings
554
+ allow(theme).to receive(:settings).and_return(settings)
555
+
556
+ # Stub ALL required files first
557
+ Dugway::Theme::REQUIRED_FILES.each do |file|
558
+ allow(theme).to receive(:read_source_file).with(file).and_return("some content")
559
+ end
560
+
561
+ # Then override the specific stub for layout.html
562
+ allow(theme).to receive(:read_source_file).with('layout.html')
563
+ .and_return('<body data-bc-page-type="home"><div data-bc-hook="header"></div><div data-bc-hook="footer"></div></body>')
564
+
565
+ # Stub color validation
566
+ allow(theme).to receive(:validate_required_color_settings).and_return(true)
567
+
568
+ # Allow warn to be called so we can spy on it
569
+ allow(Kernel).to receive(:warn)
570
+
571
+ # Call valid? and assert true (since warnings don't invalidate)
572
+ theme.valid?.should be(true)
573
+
574
+ # Verify calls
575
+ expect(Kernel).to have_received(:warn).with("Warning: Theme setting 'option_2_missing' is missing the 'section' property.")
576
+ expect(Kernel).to have_received(:warn).with("Warning: Theme setting 'option_3_invalid' has an invalid 'section' value: 'bad_section'. Allowed values are: #{valid_sections_string}.")
577
+ expect(Kernel).to have_received(:warn).with("Warning: Theme setting 'option_5_missing' is missing the 'section' property.")
578
+ expect(Kernel).to have_received(:warn).with("Warning: Theme setting 'unknown' is missing the 'section' property.")
579
+ expect(Kernel).to have_received(:warn).with("Warning: Theme setting 'option_7_invalid' has an invalid 'section' value: 'another_bad_one'. Allowed values are: #{valid_sections_string}.")
580
+ end
581
+
582
+ it "handles options without a variable name gracefully when section is invalid" do
583
+ settings = base_settings.merge({
584
+ 'options' => [{
585
+ # 'variable' key is missing
586
+ 'description' => 'Option without variable',
587
+ 'section' => 'invalid_area' # Invalid section to trigger warning
588
+ }]
589
+ })
590
+
591
+ # Stub all necessary methods
592
+ allow(theme).to receive(:settings).and_return(settings)
593
+ allow(theme).to receive(:validate_required_color_settings).and_return(true)
594
+ allow(theme).to receive(:validate_required_layout_attributes).and_return(true)
595
+ allow(theme).to receive(:validate_options_requires).and_return(true)
596
+ allow(theme).to receive(:validate_options_descriptions).and_return(true)
597
+ allow(theme).to receive(:validate_option_defaults).and_return(true)
598
+
599
+ # Stub file checks - this is crucial
600
+ Dugway::Theme::REQUIRED_FILES.each do |file|
601
+ allow(theme).to receive(:read_source_file).with(file).and_return("some content")
602
+ end
603
+
604
+ # Allow warn to be called
605
+ allow(Kernel).to receive(:warn)
606
+
607
+ # Run the validation
608
+ theme.valid?.should be(true)
609
+
610
+ # Check that the warning was issued
611
+ expect(Kernel).to have_received(:warn).with("Warning: Theme setting 'unknown' has an invalid 'section' value: 'invalid_area'. Allowed values are: #{valid_sections_string}.")
612
+ end
613
+
614
+ it "handles options without a variable name gracefully when section is missing" do
615
+ settings = base_settings.merge({
616
+ 'options' => [{
617
+ # 'variable' key is missing
618
+ 'description' => 'Option without variable or section'
619
+ # 'section' key is missing
620
+ }]
621
+ })
622
+
623
+ # Stub all necessary methods
624
+ allow(theme).to receive(:settings).and_return(settings)
625
+ allow(theme).to receive(:validate_required_color_settings).and_return(true)
626
+ allow(theme).to receive(:validate_required_layout_attributes).and_return(true)
627
+ allow(theme).to receive(:validate_options_requires).and_return(true)
628
+ allow(theme).to receive(:validate_options_descriptions).and_return(true)
629
+ allow(theme).to receive(:validate_option_defaults).and_return(true)
630
+
631
+ # Stub file checks
632
+ Dugway::Theme::REQUIRED_FILES.each do |file|
633
+ allow(theme).to receive(:read_source_file).with(file).and_return("some content")
634
+ end
635
+
636
+ # Allow warn to be called
637
+ allow(Kernel).to receive(:warn)
638
+
639
+ # Run the validation
640
+ theme.valid?.should be(true)
641
+
642
+ # Check that the warning was issued
643
+ expect(Kernel).to have_received(:warn).with("Warning: Theme setting 'unknown' is missing the 'section' property.")
644
+ end
455
645
  end
646
+
647
+ end
648
+
649
+ shared_examples "option value validation" do |value_type|
650
+ context "when type is 'select' with explicit options" do
651
+ let(:options) do
652
+ [{
653
+ 'type' => 'select',
654
+ 'variable' => 'hero_overlay_opacity',
655
+ 'options' => [['Bright', 'bright'], ['Dark', 'dark']],
656
+ value_type => 'bright'
657
+ }]
658
+ end
659
+
660
+ it "passes when #{value_type} matches a valid option" do
661
+ theme.instance_variable_set(:@errors, [])
662
+ theme.send(:validate_option_defaults)
663
+ expect(theme.errors).to be_empty
664
+ end
665
+
666
+ it "flags invalid #{value_type} values" do
667
+ options.first[value_type] = 'invalid'
668
+ theme.instance_variable_set(:@errors, [])
669
+ theme.send(:validate_option_defaults)
670
+ expect(theme.errors).to include("#{value_type.capitalize} 'invalid' is not a valid option for hero_overlay_opacity")
671
+ end
672
+ end
673
+
674
+ context "when type is 'select' with numeric range" do
675
+ let(:options) do
676
+ [{
677
+ 'type' => 'select',
678
+ 'variable' => 'featured_products',
679
+ 'options' => '0..100',
680
+ value_type => 50
681
+ }]
682
+ end
683
+
684
+ it "passes when #{value_type} is within range" do
685
+ theme.instance_variable_set(:@errors, [])
686
+ theme.send(:validate_option_defaults)
687
+ expect(theme.errors).to be_empty
688
+ end
689
+
690
+ it "flags #{value_type} outside the range" do
691
+ options.first[value_type] = 101
692
+ theme.instance_variable_set(:@errors, [])
693
+ theme.send(:validate_option_defaults)
694
+ expect(theme.errors).to include("#{value_type.capitalize} '101' is out of range for featured_products")
695
+ end
696
+ end
697
+
698
+ context "when type is 'boolean'" do
699
+ let(:options) do
700
+ [{
701
+ 'type' => 'boolean',
702
+ 'variable' => 'show_home_page_categories',
703
+ value_type => true
704
+ }]
705
+ end
706
+
707
+ it "passes when #{value_type} is a valid boolean" do
708
+ theme.instance_variable_set(:@errors, [])
709
+ theme.send(:validate_option_defaults)
710
+ expect(theme.errors).to be_empty
711
+ end
712
+
713
+ it "flags non-boolean #{value_type}" do
714
+ options.first[value_type] = 'true'
715
+ theme.instance_variable_set(:@errors, [])
716
+ theme.send(:validate_option_defaults)
717
+ expect(theme.errors).to include("#{value_type.capitalize} 'true' is not a boolean for show_home_page_categories")
718
+ end
719
+ end
720
+
721
+ context "when type is 'select' with product_orders" do
722
+ let(:options) do
723
+ [{
724
+ 'type' => 'select',
725
+ 'variable' => 'related_products_order',
726
+ 'options' => 'product_orders',
727
+ value_type => 'top-selling'
728
+ }]
729
+ end
730
+
731
+ it "passes when #{value_type} matches a valid product_orders value" do
732
+ theme.instance_variable_set(:@errors, [])
733
+ theme.send(:validate_option_defaults)
734
+ expect(theme.errors).to be_empty
735
+ end
736
+
737
+ it "flags invalid #{value_type} values" do
738
+ options.first[value_type] = 'invalid'
739
+ theme.instance_variable_set(:@errors, [])
740
+ theme.send(:validate_option_defaults)
741
+ expect(theme.errors).to include("#{value_type.capitalize} 'invalid' is not a valid option for related_products_order")
742
+ end
743
+ end
744
+ end
745
+
746
+ describe "#validate_option_defaults" do
747
+ let(:settings) { { 'options' => options } }
748
+ before { allow(theme).to receive(:settings).and_return(settings) }
749
+
750
+ it_behaves_like "option value validation", 'default'
751
+ it_behaves_like "option value validation", 'upgrade_default'
456
752
  end
457
753
 
458
754
  def read_file(file_name)