pandorga 1.11.0 → 1.13.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.
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Refs: PLT-AC-43
5
+ require "pathname"
6
+ require "yaml"
7
+ require "fileutils"
8
+ require "tmpdir"
9
+ require "open3"
10
+ require "date"
11
+
12
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
13
+ EXE = ROOT.join("exe/pandorga")
14
+ GEM_SCHEMA = ROOT.join("studio/schema.yml")
15
+ FIXTURES = ROOT.join("scripts/test/fixtures/studio-taxonomy")
16
+ TOP_LEVEL_CONFIG = FIXTURES.join("top-level/_config.yml")
17
+ PAGE_LOCAL_CONFIG = FIXTURES.join("page-local/_config.yml")
18
+
19
+ LEFTOVER_TAGS = %w[
20
+ Aesthetics
21
+ Social
22
+ Mathematics
23
+ Robotics
24
+ DevOps
25
+ brasilidade
26
+ engraçadinho
27
+ devaneio
28
+ provocação
29
+ ].freeze
30
+
31
+ def fail!(message)
32
+ warn "FAIL test-studio-taxonomy: #{message}"
33
+ exit 1
34
+ end
35
+
36
+ def run_install_functions(dest)
37
+ env = { "RUBYOPT" => "-I#{ROOT.join('lib')}" }
38
+ Open3.capture3(env, "ruby", EXE.to_s, "install-functions", dest.to_s)
39
+ end
40
+
41
+ def load_yaml(path)
42
+ YAML.safe_load(path.read, permitted_classes: [Date, Time], aliases: true) || {}
43
+ end
44
+
45
+ def find_content_collection(schema, name)
46
+ Array(schema["content"]).each do |group|
47
+ next unless group.is_a?(Hash)
48
+
49
+ Array(group["items"]).each do |item|
50
+ next unless item.is_a?(Hash)
51
+ next unless item["type"].to_s == "collection"
52
+
53
+ return item if item["name"].to_s == name.to_s
54
+ end
55
+ end
56
+ nil
57
+ end
58
+
59
+ def find_tags_field(entry)
60
+ return nil unless entry.is_a?(Hash)
61
+
62
+ Array(entry["fields"]).find { |f| f.is_a?(Hash) && f["name"].to_s == "tags" }
63
+ end
64
+
65
+ # a, b, e, d. Top-level fixture, no leftovers, overrides, and config change.
66
+ Dir.mktmpdir("pandorga-studio-tax-top-") do |dir|
67
+ site = Pathname.new(dir)
68
+ FileUtils.cp(TOP_LEVEL_CONFIG, site.join("_config.yml"))
69
+
70
+ stdout, stderr, status = run_install_functions(site)
71
+ fail!("case a CLI failed: #{stdout}\n#{stderr}") unless status.success?
72
+
73
+ installed_path = site.join("studio/schema.yml")
74
+ fail!("installed schema missing at #{installed_path}") unless installed_path.file?
75
+
76
+ schema = load_yaml(installed_path)
77
+ [
78
+ ["content.articles", find_content_collection(schema, "articles")],
79
+ ["collections.articles", schema.dig("collections", "articles")]
80
+ ].each do |label, entry|
81
+ # The gem's schema has no collections index; the installer must not add one.
82
+ next if label.start_with?("collections.") && !schema.key?("collections")
83
+
84
+ fail!("missing #{label}") unless entry.is_a?(Hash)
85
+ opts = find_tags_field(entry)&.fetch("options", nil)
86
+ fail!("missing tags options on #{label}") unless opts.is_a?(Hash)
87
+ unless opts["values"] == %w[Design Organizations]
88
+ fail!("#{label} values expected [Design, Organizations], got #{opts['values'].inspect}")
89
+ end
90
+ fail!("#{label} max expected 2, got #{opts['max'].inspect}") unless opts["max"] == 2
91
+ fail!("#{label} min expected 1, got #{opts['min'].inspect}") unless opts["min"] == 1
92
+ end
93
+
94
+ [
95
+ ["content.posts", find_content_collection(schema, "posts")],
96
+ ["collections.posts", schema.dig("collections", "posts")]
97
+ ].each do |label, entry|
98
+ # The gem's schema has no collections index; the installer must not add one.
99
+ next if label.start_with?("collections.") && !schema.key?("collections")
100
+
101
+ fail!("missing #{label}") unless entry.is_a?(Hash)
102
+ opts = find_tags_field(entry)&.fetch("options", nil)
103
+ fail!("missing tags options on #{label}") unless opts.is_a?(Hash)
104
+ unless opts["values"] == %w[alpha beta gamma]
105
+ fail!("#{label} values expected [alpha, beta, gamma], got #{opts['values'].inspect}")
106
+ end
107
+ fail!("#{label} max expected 3, got #{opts['max'].inspect}") unless opts["max"] == 3
108
+ if opts.key?("min")
109
+ fail!("#{label} should not have min key, got #{opts['min'].inspect}")
110
+ end
111
+ end
112
+
113
+ fail!("installer must not add a collections index") if schema.key?("collections")
114
+
115
+ # Free-text tags (type string, list) are not pickers and stay untouched.
116
+ projects_tags = find_tags_field(find_content_collection(schema, "projects"))
117
+ gem_projects_tags = find_tags_field(find_content_collection(load_yaml(GEM_SCHEMA), "projects"))
118
+ unless projects_tags && projects_tags == gem_projects_tags
119
+ fail!("free-text projects tags changed: #{projects_tags.inspect}")
120
+ end
121
+
122
+ # b. No leftover site vocabulary in installed schema.
123
+ raw_text = installed_path.read
124
+ LEFTOVER_TAGS.each do |word|
125
+ if raw_text.include?(word)
126
+ fail!("installed schema still contains leftover tag #{word.inspect}")
127
+ end
128
+ end
129
+
130
+ # e. Overrides applied after taxonomies.
131
+ site.join("studio.overrides.yml").write(<<~YAML)
132
+ collections:
133
+ articles:
134
+ label: Essays
135
+ YAML
136
+ stdout, stderr, status = run_install_functions(site)
137
+ fail!("case e CLI failed: #{stdout}\n#{stderr}") unless status.success?
138
+
139
+ overridden = load_yaml(installed_path)
140
+ articles_item = find_content_collection(overridden, "articles")
141
+ unless articles_item&.fetch("label", nil) == "Essays"
142
+ fail!("expected overridden articles label 'Essays', got #{articles_item&.fetch('label', nil).inspect}")
143
+ end
144
+ articles_vals = find_tags_field(articles_item)&.dig("options", "values")
145
+ unless articles_vals == %w[Design Organizations]
146
+ fail!("expected overridden articles tags [Design, Organizations], got #{articles_vals.inspect}")
147
+ end
148
+ FileUtils.rm_f(site.join("studio.overrides.yml"))
149
+
150
+ # d. Changing _config.yml changes the output.
151
+ site.join("_config.yml").write(<<~YAML)
152
+ pandorga:
153
+ taxonomies:
154
+ articles:
155
+ tags: [Zeta]
156
+ max: 2
157
+ posts:
158
+ tags: [alpha, beta, gamma]
159
+ max: 3
160
+ YAML
161
+ stdout, stderr, status = run_install_functions(site)
162
+ fail!("case d CLI failed: #{stdout}\n#{stderr}") unless status.success?
163
+
164
+ updated = load_yaml(installed_path)
165
+ updated_vals = find_tags_field(find_content_collection(updated, "articles"))&.dig("options", "values")
166
+ unless updated_vals == %w[Zeta]
167
+ fail!("expected updated articles tags [Zeta], got #{updated_vals.inspect}")
168
+ end
169
+ end
170
+
171
+ # c. Page-local fixture: articles falls back to pages[].taxonomy; posts stays [] with a warning.
172
+ Dir.mktmpdir("pandorga-studio-tax-page-") do |dir|
173
+ site = Pathname.new(dir)
174
+ FileUtils.cp(PAGE_LOCAL_CONFIG, site.join("_config.yml"))
175
+
176
+ stdout, stderr, status = run_install_functions(site)
177
+ fail!("case c CLI failed: #{stdout}\n#{stderr}") unless status.success?
178
+
179
+ schema = load_yaml(site.join("studio/schema.yml"))
180
+ articles_opts = find_tags_field(find_content_collection(schema, "articles"))&.fetch("options", nil)
181
+ fail!("case c missing articles tags options") unless articles_opts.is_a?(Hash)
182
+ unless articles_opts["values"] == %w[Notes]
183
+ fail!("case c articles values expected [Notes], got #{articles_opts['values'].inspect}")
184
+ end
185
+ unless articles_opts["max"] == 1
186
+ fail!("case c articles max expected 1, got #{articles_opts['max'].inspect}")
187
+ end
188
+
189
+ posts_opts = find_tags_field(find_content_collection(schema, "posts"))&.fetch("options", nil)
190
+ fail!("case c missing posts tags options") unless posts_opts.is_a?(Hash)
191
+ unless posts_opts["values"] == []
192
+ fail!("case c posts values expected [], got #{posts_opts['values'].inspect}")
193
+ end
194
+
195
+ combined = "#{stdout}\n#{stderr}"
196
+ unless combined.match?(/warning:.*posts/i)
197
+ fail!("case c expected warning mentioning posts, got:\n#{combined}")
198
+ end
199
+ end
200
+
201
+ # f. Gem source: studio/schema.yml has values: [] on every select tags field.
202
+ gem_schema = load_yaml(GEM_SCHEMA)
203
+ gem_tag_fields = 0
204
+ Array(gem_schema["content"]).each do |group|
205
+ next unless group.is_a?(Hash)
206
+
207
+ Array(group["items"]).each do |item|
208
+ next unless item.is_a?(Hash) && item["type"].to_s == "collection"
209
+
210
+ field = find_tags_field(item)
211
+ next unless field && (field["type"].to_s == "select" || field["options"].is_a?(Hash))
212
+
213
+ gem_tag_fields += 1
214
+ vals = field.dig("options", "values")
215
+ unless vals == []
216
+ fail!("gem studio/schema.yml #{item['name']} tags values expected [], got #{vals.inspect}")
217
+ end
218
+ end
219
+ end
220
+ if gem_schema["collections"].is_a?(Hash)
221
+ gem_schema["collections"].each do |name, item|
222
+ field = find_tags_field(item)
223
+ next unless field && (field["type"].to_s == "select" || field["options"].is_a?(Hash))
224
+
225
+ vals = field.dig("options", "values")
226
+ unless vals == []
227
+ fail!("gem studio/schema.yml collections.#{name} tags values expected [], got #{vals.inspect}")
228
+ end
229
+ end
230
+ end
231
+ fail!("expected select tags fields in gem studio/schema.yml") if gem_tag_fields.zero?
232
+
233
+ # g. No _config.yml in destination: command succeeds and copies schema unchanged.
234
+ Dir.mktmpdir("pandorga-studio-tax-noconfig-") do |dir|
235
+ site = Pathname.new(dir)
236
+ stdout, stderr, status = run_install_functions(site)
237
+ fail!("case g CLI failed: #{stdout}\n#{stderr}") unless status.success?
238
+
239
+ installed_path = site.join("studio/schema.yml")
240
+ fail!("case g missing studio/schema.yml") unless installed_path.file?
241
+ unless installed_path.read == GEM_SCHEMA.read
242
+ fail!("case g expected studio/schema.yml to be copied unchanged when _config.yml is absent")
243
+ end
244
+ end
245
+
246
+ puts "PASS test-studio-taxonomy"
@@ -0,0 +1,380 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Refs: PLT-AC-44
5
+ require "fileutils"
6
+ require "json"
7
+ require "open3"
8
+ require "pathname"
9
+ require "tmpdir"
10
+ require "yaml"
11
+
12
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
13
+ EXE = ROOT.join("exe/pandorga")
14
+ EXPORT = ROOT.join("scripts/content/export-content-json.rb")
15
+
16
+ def fail!(message)
17
+ warn "FAIL test-thumbnail-variants: #{message}"
18
+ exit 1
19
+ end
20
+
21
+ def find_imagemagick
22
+ %w[magick convert].find do |bin|
23
+ _out, _err, status = Open3.capture3("which", bin)
24
+ status.success?
25
+ rescue Errno::ENOENT
26
+ false
27
+ end
28
+ end
29
+
30
+ magick_bin = find_imagemagick
31
+ unless magick_bin
32
+ if ENV["CI"]
33
+ fail!("ImageMagick is missing on CI")
34
+ else
35
+ puts "SKIP test-thumbnail-variants: ImageMagick not installed"
36
+ exit 0
37
+ end
38
+ end
39
+
40
+ def run_magick(magick_bin, *args)
41
+ cmd = magick_bin == "magick" ? ["magick", *args] : [args.first == "identify" ? "identify" : "convert", *(args.first == "identify" ? args.drop(1) : args)]
42
+ out, err, status = Open3.capture3(*cmd)
43
+ fail!("ImageMagick failed (#{cmd.join(' ')}): #{out}\n#{err}") unless status.success?
44
+ out
45
+ end
46
+
47
+ def create_image(magick_bin, path, size, color = "steelblue")
48
+ FileUtils.mkdir_p(path.dirname)
49
+ run_magick(magick_bin, "-size", size, "xc:#{color}", path.to_s)
50
+ end
51
+
52
+ def inject_minimal_exif!(jpeg_path)
53
+ data = File.binread(jpeg_path)
54
+ return unless data.start_with?("\xFF\xD8".b)
55
+
56
+ # Minimal big-endian TIFF header with Artist tag (0x013B) = "Test"
57
+ tiff = "MM\x00*\x00\x00\x00\x08\x00\x01\x01\x3B\x00\x02\x00\x00\x00\x05\x00\x00\x00\x1ATest\x00".b
58
+ payload = "Exif\x00\x00".b + tiff
59
+ app1 = "\xFF\xE1".b + [payload.bytesize + 2].pack("n") + payload
60
+ File.binwrite(jpeg_path, data[0, 2] + app1 + data[2..])
61
+ end
62
+
63
+ def identify_info(magick_bin, path)
64
+ raw = run_magick(magick_bin, "identify", "-format", "%w %h %m|%[EXIF:*]", path.to_s)
65
+ dims, exif = raw.split("|", 2)
66
+ w, h, fmt = dims.to_s.strip.split(/\s+/, 3)
67
+ { width: w.to_i, height: h.to_i, format: fmt.to_s, exif: exif.to_s.strip }
68
+ end
69
+
70
+ def write_fixture_site(site, magick_bin)
71
+ site.join("_config.yml").write(<<~YAML)
72
+ title: "Test Site"
73
+ url: "https://example.com"
74
+ pandorga:
75
+ identity:
76
+ name: "Ada Example"
77
+ url: "https://example.com"
78
+ content:
79
+ backend: static
80
+ pages:
81
+ - key: projects
82
+ template: portfolio
83
+ collection: projects
84
+ path: /pages/projects/
85
+ detail: /projects/:slug/
86
+ YAML
87
+
88
+ covers = site.join("content/media/images/covers")
89
+ create_image(magick_bin, covers.join("wide.jpg"), "3000x2000", "navy")
90
+ create_image(magick_bin, covers.join("tall.png"), "1200x1600", "darkgreen")
91
+ create_image(magick_bin, covers.join("small.jpg"), "800x450", "darkred")
92
+ FileUtils.mkdir_p(covers)
93
+ covers.join("clip.mp4").write("fake video bytes")
94
+
95
+ projects_dir = site.join("content/collections/projects")
96
+ FileUtils.mkdir_p(projects_dir)
97
+
98
+ entries = [
99
+ ["wide-a", "Wide Alpha", "/media/images/covers/wide.jpg", 1],
100
+ ["wide-b", "Wide Beta", "/media/images/covers/wide.jpg", 2],
101
+ ["tall-p", "Tall Project", "/media/images/covers/tall.png", 3],
102
+ ["small-p", "Small Project", "/media/images/covers/small.jpg", 4],
103
+ ["ext-url", "External Thumbnail", "https://example.com/x.jpg", 5],
104
+ ["video-p", "Video Thumbnail", "/media/images/covers/clip.mp4", 6],
105
+ ["missing-p", "Missing Thumbnail", "/media/images/covers/nope.jpg", 7]
106
+ ]
107
+
108
+ entries.each do |key, title, thumb, order|
109
+ projects_dir.join("#{key}.md").write(<<~MD)
110
+ ---
111
+ key: #{key}
112
+ project: #{title}
113
+ title: #{title}
114
+ tagline: "Summary for #{title}."
115
+ start_date: "2025-01"
116
+ status: ongoing
117
+ label: Ruby
118
+ icon: terminal
119
+ sort_order: #{order}
120
+ thumbnail: #{thumb}
121
+ tags:
122
+ - Ruby
123
+ ---
124
+
125
+ Body for #{title}.
126
+ MD
127
+ end
128
+ end
129
+
130
+ begin
131
+ require "pandorga/thumbnail_variants"
132
+ rescue LoadError => e
133
+ fail!("cannot load pandorga/thumbnail_variants: #{e.message}")
134
+ end
135
+
136
+ # 1. Library entry point + idempotency + regeneration when original is newer.
137
+ Dir.mktmpdir("pandorga-thumb-lib-") do |dir|
138
+ site = Pathname.new(dir)
139
+ write_fixture_site(site, magick_bin)
140
+
141
+ covers = site.join("content/media/images/covers")
142
+ variants = covers.join("variants")
143
+
144
+ summary = Pandorga::ThumbnailVariants.generate(site)
145
+ fail!("expected generate to return a Hash, got #{summary.inspect}") unless summary.is_a?(Hash)
146
+
147
+ # Check wide.jpg variants: 480x270, 960x540, 1440x810
148
+ [[480, 270], [960, 540], [1440, 810]].each do |w, h|
149
+ vpath = variants.join("wide-#{w}.jpg")
150
+ fail!("missing #{vpath}") unless vpath.file?
151
+ info = identify_info(magick_bin, vpath)
152
+ fail!("wide-#{w}.jpg expected #{w}x#{h}, got #{info[:width]}x#{info[:height]}") unless info[:width] == w && info[:height] == h
153
+ fail!("wide-#{w}.jpg expected JPEG, got #{info[:format]}") unless info[:format] == "JPEG"
154
+ fail!("wide-#{w}.jpg expected empty EXIF, got #{info[:exif].inspect}") unless info[:exif].empty?
155
+ end
156
+
157
+ # Check tall.png (1200x1600): 480 and 960 exist, 1440 skipped
158
+ [[480, 270], [960, 540]].each do |w, h|
159
+ vpath = variants.join("tall-#{w}.jpg")
160
+ fail!("missing #{vpath}") unless vpath.file?
161
+ info = identify_info(magick_bin, vpath)
162
+ fail!("tall-#{w}.jpg expected #{w}x#{h}, got #{info[:width]}x#{info[:height]}") unless info[:width] == w && info[:height] == h
163
+ fail!("tall-#{w}.jpg expected JPEG, got #{info[:format]}") unless info[:format] == "JPEG"
164
+ fail!("tall-#{w}.jpg expected empty EXIF, got #{info[:exif].inspect}") unless info[:exif].empty?
165
+ end
166
+ fail!("tall-1440.jpg must be skipped (no upscaling)") if variants.join("tall-1440.jpg").exist?
167
+
168
+ # Check small.jpg (800x450): only 480 exists, 960 and 1440 skipped
169
+ v480 = variants.join("small-480.jpg")
170
+ fail!("missing #{v480}") unless v480.file?
171
+ info480 = identify_info(magick_bin, v480)
172
+ fail!("small-480.jpg expected 480x270, got #{info480[:width]}x#{info480[:height]}") unless info480[:width] == 480 && info480[:height] == 270
173
+ fail!("small-480.jpg expected JPEG, got #{info480[:format]}") unless info480[:format] == "JPEG"
174
+ fail!("small-480.jpg expected empty EXIF, got #{info480[:exif].inspect}") unless info480[:exif].empty?
175
+ fail!("small-960.jpg must be skipped") if variants.join("small-960.jpg").exist?
176
+ fail!("small-1440.jpg must be skipped") if variants.join("small-1440.jpg").exist?
177
+
178
+ # Nothing written for URL, video, missing
179
+ written_names = variants.children.map { |p| p.basename.to_s }.sort
180
+ expected_names = %w[
181
+ small-480.jpg
182
+ tall-480.jpg
183
+ tall-960.jpg
184
+ wide-1440.jpg
185
+ wide-480.jpg
186
+ wide-960.jpg
187
+ ]
188
+ fail!("unexpected files in variants/: #{written_names.inspect}") unless written_names == expected_names
189
+
190
+ # Check srcset_for helper
191
+ wide_srcset = Pandorga::ThumbnailVariants.srcset_for(site, "/media/images/covers/wide.jpg")
192
+ fail!("unexpected wide srcset: #{wide_srcset.inspect}") unless wide_srcset ==
193
+ "/media/images/covers/variants/wide-480.jpg 480w, /media/images/covers/variants/wide-960.jpg 960w, /media/images/covers/variants/wide-1440.jpg 1440w"
194
+
195
+ tall_srcset = Pandorga::ThumbnailVariants.srcset_for(site, "/media/images/covers/tall.png")
196
+ fail!("unexpected tall srcset: #{tall_srcset.inspect}") unless tall_srcset ==
197
+ "/media/images/covers/variants/tall-480.jpg 480w, /media/images/covers/variants/tall-960.jpg 960w"
198
+
199
+ small_srcset = Pandorga::ThumbnailVariants.srcset_for(site, "/media/images/covers/small.jpg")
200
+ fail!("unexpected small srcset: #{small_srcset.inspect}") unless small_srcset ==
201
+ "/media/images/covers/variants/small-480.jpg 480w"
202
+
203
+ fail!("expected nil srcset for external URL") unless Pandorga::ThumbnailVariants.srcset_for(site, "https://example.com/x.jpg").nil?
204
+ fail!("expected nil srcset for video") unless Pandorga::ThumbnailVariants.srcset_for(site, "/media/images/covers/clip.mp4").nil?
205
+ fail!("expected nil srcset for missing file") unless Pandorga::ThumbnailVariants.srcset_for(site, "/media/images/covers/nope.jpg").nil?
206
+
207
+ # Idempotent rerun: set variant mtimes in the future of original, verify rerun keeps mtimes
208
+ past = Time.now - 120
209
+ variant_time = Time.now - 60
210
+ [covers.join("wide.jpg"), covers.join("tall.png"), covers.join("small.jpg")].each do |orig|
211
+ File.utime(past, past, orig.to_s)
212
+ end
213
+ variants.children.each { |v| File.utime(variant_time, variant_time, v.to_s) }
214
+ mtimes_before = variants.children.to_h { |v| [v.basename.to_s, v.mtime] }
215
+
216
+ Pandorga::ThumbnailVariants.generate(site)
217
+ mtimes_after = variants.children.to_h { |v| [v.basename.to_s, v.mtime] }
218
+ fail!("rerun modified up-to-date variants: #{mtimes_before.inspect} vs #{mtimes_after.inspect}") unless mtimes_before == mtimes_after
219
+
220
+ # Touch original newer: only that image's variant set is regenerated
221
+ newer = Time.now
222
+ File.utime(newer, newer, covers.join("small.jpg").to_s)
223
+ Pandorga::ThumbnailVariants.generate(site)
224
+ fail!("expected small-480.jpg to be regenerated when small.jpg is newer") unless variants.join("small-480.jpg").mtime > mtimes_before["small-480.jpg"]
225
+ fail!("expected wide-480.jpg mtime unchanged") unless variants.join("wide-480.jpg").mtime == mtimes_before["wide-480.jpg"]
226
+ end
227
+
228
+ # 2. CLI entry point `exe/pandorga thumbnails <root>`
229
+ Dir.mktmpdir("pandorga-thumb-cli-") do |dir|
230
+ site = Pathname.new(dir)
231
+ write_fixture_site(site, magick_bin)
232
+
233
+ env = { "RUBYOPT" => "-I#{ROOT.join('lib')}" }
234
+ stdout, stderr, status = Open3.capture3(env, "ruby", EXE.to_s, "thumbnails", site.to_s)
235
+ fail!("CLI pandorga thumbnails failed:\n#{stdout}\n#{stderr}") unless status.success?
236
+
237
+ variants = site.join("content/media/images/covers/variants")
238
+ %w[wide-480.jpg wide-960.jpg wide-1440.jpg tall-480.jpg tall-960.jpg small-480.jpg].each do |name|
239
+ fail!("CLI did not write #{name}") unless variants.join(name).file?
240
+ end
241
+ end
242
+
243
+ # 3. Full gem export runs generation itself and rewrites thumbnail_srcset URLs
244
+ Dir.mktmpdir("pandorga-thumb-export-") do |dir|
245
+ site = Pathname.new(dir)
246
+ write_fixture_site(site, magick_bin)
247
+ out = site.join("_content_json")
248
+ variants = site.join("content/media/images/covers/variants")
249
+ fail!("variants must not exist before export") if variants.exist?
250
+
251
+ env = {
252
+ "RUBYOPT" => "-I#{ROOT.join('lib')}",
253
+ "PANDORGA_SITE_ROOT" => site.to_s,
254
+ "CONTENT_API_BASE_URL" => "https://cdn.example.com",
255
+ "CONTENT_EXPORT_SKIP_VALIDATE" => "1"
256
+ }
257
+ stdout, stderr, status = Open3.capture3(
258
+ env, "ruby", EXPORT.to_s,
259
+ site.join("content").to_s, out.to_s,
260
+ chdir: site.to_s
261
+ )
262
+ fail!("export failed:\n#{stdout}\n#{stderr}") unless status.success?
263
+ fail!("export did not generate variants/") unless variants.join("wide-480.jpg").file?
264
+
265
+ wide_json = JSON.parse(out.join("collections/projects/wide-a.json").read)
266
+ wide_fm = wide_json.fetch("front_matter")
267
+ expected_wide_srcset = [
268
+ "https://cdn.example.com/media/images/covers/variants/wide-480.jpg 480w",
269
+ "https://cdn.example.com/media/images/covers/variants/wide-960.jpg 960w",
270
+ "https://cdn.example.com/media/images/covers/variants/wide-1440.jpg 1440w"
271
+ ].join(", ")
272
+ unless wide_fm["thumbnail"] == "https://cdn.example.com/media/images/covers/wide.jpg"
273
+ fail!("wide-a thumbnail unexpected: #{wide_fm['thumbnail'].inspect}")
274
+ end
275
+ unless wide_fm["thumbnail_srcset"] == expected_wide_srcset
276
+ fail!("wide-a thumbnail_srcset unexpected: #{wide_fm['thumbnail_srcset'].inspect}")
277
+ end
278
+
279
+ tall_fm = JSON.parse(out.join("collections/projects/tall-p.json").read).fetch("front_matter")
280
+ expected_tall_srcset = [
281
+ "https://cdn.example.com/media/images/covers/variants/tall-480.jpg 480w",
282
+ "https://cdn.example.com/media/images/covers/variants/tall-960.jpg 960w"
283
+ ].join(", ")
284
+ unless tall_fm["thumbnail_srcset"] == expected_tall_srcset
285
+ fail!("tall-p thumbnail_srcset unexpected: #{tall_fm['thumbnail_srcset'].inspect}")
286
+ end
287
+
288
+ small_fm = JSON.parse(out.join("collections/projects/small-p.json").read).fetch("front_matter")
289
+ expected_small_srcset = "https://cdn.example.com/media/images/covers/variants/small-480.jpg 480w"
290
+ unless small_fm["thumbnail_srcset"] == expected_small_srcset
291
+ fail!("small-p thumbnail_srcset unexpected: #{small_fm['thumbnail_srcset'].inspect}")
292
+ end
293
+
294
+ %w[ext-url video-p missing-p].each do |slug|
295
+ fm = JSON.parse(out.join("collections/projects/#{slug}.json").read).fetch("front_matter")
296
+ if fm.key?("thumbnail_srcset")
297
+ fail!("#{slug} must not have thumbnail_srcset, got #{fm['thumbnail_srcset'].inspect}")
298
+ end
299
+ end
300
+
301
+ # Also check writing_index.json carries thumbnail_srcset for the home specimen
302
+ index = JSON.parse(out.join("data/writing_index.json").read)
303
+ wide_idx = index.find { |e| e["slug"] == "wide-a" }
304
+ fail!("wide-a missing from writing_index.json") unless wide_idx
305
+ unless wide_idx["thumbnail_srcset"] == expected_wide_srcset
306
+ fail!("writing_index wide-a thumbnail_srcset unexpected: #{wide_idx['thumbnail_srcset'].inspect}")
307
+ end
308
+ %w[ext-url video-p missing-p].each do |slug|
309
+ entry = index.find { |e| e["slug"] == slug }
310
+ fail!("#{slug} missing from writing_index.json") unless entry
311
+ if entry.key?("thumbnail_srcset")
312
+ fail!("writing_index #{slug} must not have thumbnail_srcset, got #{entry['thumbnail_srcset'].inspect}")
313
+ end
314
+ end
315
+ end
316
+
317
+ # 4. Without ImageMagick: export succeeds, prints one warning line, writes no variants and no thumbnail_srcset
318
+ Dir.mktmpdir("pandorga-thumb-nomagick-") do |dir|
319
+ site = Pathname.new(dir)
320
+ write_fixture_site(site, magick_bin)
321
+ out = site.join("_content_json")
322
+
323
+ env = {
324
+ "RUBYOPT" => "-I#{ROOT.join('lib')}",
325
+ "PANDORGA_SITE_ROOT" => site.to_s,
326
+ "CONTENT_API_BASE_URL" => "",
327
+ "CONTENT_EXPORT_SKIP_VALIDATE" => "1",
328
+ "PANDORGA_IMAGEMAGICK" => "/nonexistent/magick"
329
+ }
330
+ stdout, stderr, status = Open3.capture3(
331
+ env, "ruby", EXPORT.to_s,
332
+ site.join("content").to_s, out.to_s,
333
+ chdir: site.to_s
334
+ )
335
+ fail!("export without ImageMagick failed:\n#{stdout}\n#{stderr}") unless status.success?
336
+
337
+ combined_lines = "#{stdout}\n#{stderr}".lines.map(&:strip).reject(&:empty?)
338
+ warnings = combined_lines.select { |l| l.match?(/warning.*imagemagick|imagemagick.*warning/i) }
339
+ unless warnings.length == 1
340
+ fail!("expected exactly 1 ImageMagick warning line, got #{warnings.length}: #{warnings.inspect}\nAll output:\n#{stdout}\n#{stderr}")
341
+ end
342
+
343
+ variants = site.join("content/media/images/covers/variants")
344
+ if variants.exist? && variants.children.any?
345
+ fail!("expected no variants written when ImageMagick is unavailable")
346
+ end
347
+
348
+ wide_fm = JSON.parse(out.join("collections/projects/wide-a.json").read).fetch("front_matter")
349
+ if wide_fm.key?("thumbnail_srcset")
350
+ fail!("expected no thumbnail_srcset when ImageMagick is unavailable, got #{wide_fm['thumbnail_srcset'].inspect}")
351
+ end
352
+ end
353
+
354
+ # 5. An unreadable image warns, keeps its original, and does not stop the export.
355
+ Dir.mktmpdir("pandorga-thumb-corrupt-") do |dir|
356
+ site = Pathname.new(dir)
357
+ write_fixture_site(site, magick_bin)
358
+ site.join("content/media/images/covers/small.jpg").write("not a jpeg")
359
+ out = site.join("_content_json")
360
+ env = {
361
+ "RUBYOPT" => "-I#{ROOT.join('lib')}",
362
+ "PANDORGA_SITE_ROOT" => site.to_s,
363
+ "CONTENT_API_BASE_URL" => "",
364
+ "CONTENT_EXPORT_SKIP_VALIDATE" => "1"
365
+ }
366
+ stdout, stderr, status = Open3.capture3(
367
+ env, "ruby", EXPORT.to_s,
368
+ site.join("content").to_s, out.to_s,
369
+ chdir: site.to_s
370
+ )
371
+ fail!("export with an unreadable image failed:\n#{stdout}\n#{stderr}") unless status.success?
372
+ fail!("expected a warning naming small.jpg") unless "#{stdout}\n#{stderr}".match?(/warning.*small\.jpg/)
373
+ variants = site.join("content/media/images/covers/variants")
374
+ fail!("unreadable image must not get variants") if variants.join("small-480.jpg").exist?
375
+ fail!("readable images must still get variants") unless variants.join("wide-480.jpg").file?
376
+ small_fm = JSON.parse(out.join("collections/projects/small-p.json").read).fetch("front_matter")
377
+ fail!("unreadable image must not get thumbnail_srcset") if small_fm.key?("thumbnail_srcset")
378
+ end
379
+
380
+ puts "PASS test-thumbnail-variants"
data/scripts/validate.sh CHANGED
@@ -44,6 +44,7 @@ TESTS=(
44
44
  scripts/test/test-studio-api-contract.rb
45
45
  scripts/test/test-studio-rewrite-api-contract.rb
46
46
  scripts/test/test-studio-schema-composition.rb
47
+ scripts/test/test-studio-taxonomy.rb
47
48
  scripts/test/test-text-excerpt.rb
48
49
  scripts/test/test-git-chronology.rb
49
50
  scripts/test/test-math-row-breaks.rb
@@ -70,6 +71,7 @@ TESTS=(
70
71
  scripts/test/test-css-delivery.rb
71
72
  scripts/test/test-discovery.rb
72
73
  scripts/test/test-hero-portraits.rb
74
+ scripts/test/test-thumbnail-variants.rb
73
75
  )
74
76
 
75
77
  failed=0
@@ -90,6 +92,11 @@ if ! node scripts/test/test-hero-portraits.mjs; then
90
92
  failed=1
91
93
  fi
92
94
 
95
+ echo "-- scripts/test/test-poster-srcset.mjs"
96
+ if ! node scripts/test/test-poster-srcset.mjs; then
97
+ failed=1
98
+ fi
99
+
93
100
  echo "-- scripts/test/test-studio-pipeline-config.mjs"
94
101
  if ! node scripts/test/test-studio-pipeline-config.mjs; then
95
102
  failed=1