pandorga 1.12.0 → 1.14.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,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"
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ // PLT-AC-47. Pure helper unit tests sliced from _includes/page/timeline-section.html
3
+ // Executes panTarget and centerPreservingScroll from source rather than a copy.
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+
9
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
10
+ const source = fs.readFileSync(
11
+ path.join(root, '_includes/page/timeline-section.html'),
12
+ 'utf8'
13
+ );
14
+
15
+ const start = source.indexOf('function panTarget');
16
+ const end = source.indexOf('function parseDate');
17
+ if (start < 0 || end < start) {
18
+ console.error('FAIL: panTarget / centerPreservingScroll missing from timeline-section.html');
19
+ process.exit(1);
20
+ }
21
+
22
+ const slice = source.slice(start, end);
23
+ if (!slice.includes('function centerPreservingScroll')) {
24
+ console.error('FAIL: centerPreservingScroll missing from slice');
25
+ process.exit(1);
26
+ }
27
+
28
+ const api = new Function(
29
+ `${slice}\nreturn { panTarget, centerPreservingScroll };`
30
+ )();
31
+
32
+ const { panTarget, centerPreservingScroll } = api;
33
+
34
+ let failed = 0;
35
+
36
+ function check(condition, message) {
37
+ if (!condition) {
38
+ console.error(`FAIL: ${message}`);
39
+ failed += 1;
40
+ }
41
+ }
42
+
43
+ // ── panTarget tests (PLT-AC-47) ─────────────────────────────────────────────
44
+ // Check step sizes 10% (0.1) and 90% (0.9), and clamping at both bounds (0 and maxScroll).
45
+
46
+ const visibleW = 1000;
47
+ const maxScroll = 4000;
48
+
49
+ // Step 10% right (forward in time)
50
+ check(panTarget(500, visibleW, maxScroll, 0.1) === 600, 'panTarget 10% right from 500 should be 600');
51
+ // Step 10% left (backward in time)
52
+ check(panTarget(500, visibleW, maxScroll, -0.1) === 400, 'panTarget 10% left from 500 should be 400');
53
+
54
+ // Step 90% right
55
+ check(panTarget(500, visibleW, maxScroll, 0.9) === 1400, 'panTarget 90% right from 500 should be 1400');
56
+ // Step 90% left
57
+ check(panTarget(1500, visibleW, maxScroll, -0.9) === 600, 'panTarget 90% left from 1500 should be 600');
58
+
59
+ // Lower bound clamping (scrollLeft <= 0)
60
+ check(panTarget(50, visibleW, maxScroll, -0.1) === 0, 'panTarget clamps at lower bound (10% left)');
61
+ check(panTarget(500, visibleW, maxScroll, -0.9) === 0, 'panTarget clamps at lower bound (90% left)');
62
+ check(panTarget(0, visibleW, maxScroll, -0.1) === 0, 'panTarget at 0 stays at 0');
63
+
64
+ // Upper bound clamping (scrollLeft >= maxScroll)
65
+ check(panTarget(3950, visibleW, maxScroll, 0.1) === 4000, 'panTarget clamps at upper bound (10% right)');
66
+ check(panTarget(3500, visibleW, maxScroll, 0.9) === 4000, 'panTarget clamps at upper bound (90% right)');
67
+ check(panTarget(4000, visibleW, maxScroll, 0.1) === 4000, 'panTarget at maxScroll stays at maxScroll');
68
+
69
+ // ── centerPreservingScroll tests (PLT-AC-47) ────────────────────────────────
70
+ // Center time preserved (within 1 day) across zoom factors 0.75 and 1.33.
71
+
72
+ const MS_DAY = 86400000;
73
+ const MS_YEAR = 365.25 * MS_DAY;
74
+ const LABEL_W = 96;
75
+
76
+ // Simulated timeline: 10 years of range
77
+ const minT = new Date(2015, 0, 1).getTime();
78
+ const maxT = new Date(2025, 0, 1).getTime();
79
+ const rangeMs = maxT - minT;
80
+
81
+ const initialZoomYears = 2;
82
+ const visibleChartW = visibleW - LABEL_W;
83
+ const initialChartW = visibleChartW * (rangeMs / (initialZoomYears * MS_YEAR));
84
+
85
+ function makeXPosMs(chartW) {
86
+ return function (t) {
87
+ return LABEL_W + ((t - minT) / rangeMs) * chartW;
88
+ };
89
+ }
90
+
91
+ function timeFromX(x, chartW) {
92
+ const chartX = x - LABEL_W;
93
+ return minT + (chartX / chartW) * rangeMs;
94
+ }
95
+
96
+ // Suppose wrapper was scrolled to 1200px at 2 yr zoom
97
+ const initialScrollLeft = 1200;
98
+ const initialCenterPx = initialScrollLeft + visibleW / 2;
99
+ const centerMs = timeFromX(initialCenterPx, initialChartW);
100
+
101
+ // Zoom in: factor 0.75
102
+ const zoomInYears = initialZoomYears * 0.75;
103
+ const chartWZoomIn = visibleChartW * (rangeMs / (zoomInYears * MS_YEAR));
104
+ const maxScrollZoomIn = Math.max(0, LABEL_W + chartWZoomIn + 32 - visibleW);
105
+ const xPosMsZoomIn = makeXPosMs(chartWZoomIn);
106
+
107
+ const newScrollLeftZoomIn = centerPreservingScroll(centerMs, xPosMsZoomIn, visibleW, maxScrollZoomIn);
108
+ const newCenterPxZoomIn = newScrollLeftZoomIn + visibleW / 2;
109
+ const centerMsAfterZoomIn = timeFromX(newCenterPxZoomIn, chartWZoomIn);
110
+ const diffDaysZoomIn = Math.abs(centerMsAfterZoomIn - centerMs) / MS_DAY;
111
+
112
+ check(
113
+ diffDaysZoomIn <= 1.0,
114
+ `center time preserved within 1 day on zoom factor 0.75 (drift: ${diffDaysZoomIn.toFixed(3)} days)`
115
+ );
116
+
117
+ // Zoom out: factor 1.33
118
+ const zoomOutYears = initialZoomYears * 1.33;
119
+ const chartWZoomOut = visibleChartW * (rangeMs / (zoomOutYears * MS_YEAR));
120
+ const maxScrollZoomOut = Math.max(0, LABEL_W + chartWZoomOut + 32 - visibleW);
121
+ const xPosMsZoomOut = makeXPosMs(chartWZoomOut);
122
+
123
+ const newScrollLeftZoomOut = centerPreservingScroll(centerMs, xPosMsZoomOut, visibleW, maxScrollZoomOut);
124
+ const newCenterPxZoomOut = newScrollLeftZoomOut + visibleW / 2;
125
+ const centerMsAfterZoomOut = timeFromX(newCenterPxZoomOut, chartWZoomOut);
126
+ const diffDaysZoomOut = Math.abs(centerMsAfterZoomOut - centerMs) / MS_DAY;
127
+
128
+ check(
129
+ diffDaysZoomOut <= 1.0,
130
+ `center time preserved within 1 day on zoom factor 1.33 (drift: ${diffDaysZoomOut.toFixed(3)} days)`
131
+ );
132
+
133
+ // Clamping at lower bound
134
+ const startCenterMs = minT; // Start of timeline
135
+ const clampedStartScroll = centerPreservingScroll(startCenterMs, xPosMsZoomIn, visibleW, maxScrollZoomIn);
136
+ check(clampedStartScroll === 0, 'centerPreservingScroll clamps to 0 at timeline start');
137
+
138
+ // Clamping at upper bound
139
+ const endCenterMs = maxT; // End of timeline
140
+ const clampedEndScroll = centerPreservingScroll(endCenterMs, xPosMsZoomIn, visibleW, maxScrollZoomIn);
141
+ check(clampedEndScroll === Math.round(maxScrollZoomIn), 'centerPreservingScroll clamps to maxScroll at timeline end');
142
+
143
+ if (failed) {
144
+ console.error(`timeline pan: ${failed} failure(s)`);
145
+ process.exit(1);
146
+ }
147
+ console.log('PASS test-timeline-pan.mjs (PLT-AC-47)');
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # CV timeline temporal pan static gate (PLT-AC-47, docs/features/timeline-pan.md).
5
+ #
6
+ # Asserts:
7
+ # - wrapper has tabindex="0", role="region", and aria-label
8
+ # - pan prev/next and Today buttons exist with aria-labels
9
+ # - script registers pointerdown, pointermove, pointerup, and keydown handlers
10
+ # - shiftKey is checked on the selection path
11
+ # - changeZoom no longer calls scrollToToday()
12
+ # - prefers-reduced-motion check is present
13
+ # - hint text "Shift+drag to select a period" is present
14
+
15
+ require "pathname"
16
+
17
+ ROOT = Pathname.new(__dir__).join("..", "..").expand_path
18
+ TIMELINE_HTML = ROOT.join("_includes/page/timeline-section.html")
19
+
20
+ def fail!(msg)
21
+ warn "FAIL test-timeline-pan: #{msg}"
22
+ exit 1
23
+ end
24
+
25
+ source = TIMELINE_HTML.read
26
+
27
+ # 1. Wrapper accessibility attributes
28
+ wrapper_match = source[/<div[^>]*id=["']timeline-chart-wrapper["'][^>]*>/]
29
+ fail!("missing #timeline-chart-wrapper") unless wrapper_match
30
+ fail!("wrapper missing tabindex=\"0\"") unless wrapper_match.include?('tabindex="0"')
31
+ fail!("wrapper missing role=\"region\"") unless wrapper_match.include?('role="region"')
32
+ fail!("wrapper missing aria-label") unless wrapper_match.include?('aria-label=')
33
+
34
+ # 2. Pan and Today buttons with aria-labels inside #timeline-zoom-controls
35
+ zoom_controls = source[/<div[^>]*id=["']timeline-zoom-controls["'][^>]*>.*?<\/div>/m]
36
+ fail!("missing #timeline-zoom-controls") unless zoom_controls
37
+
38
+ fail!("missing pan prev button (‹)") unless zoom_controls.include?("‹")
39
+ fail!("missing pan next button (›)") unless zoom_controls.include?("›")
40
+ fail!("missing Today button") unless zoom_controls.include?("Today")
41
+
42
+ prev_btn = zoom_controls[/<button[^>]*>[^<]*‹[^<]*<\/button>/m]
43
+ fail!("pan prev button missing aria-label") unless prev_btn && prev_btn.include?("aria-label=")
44
+
45
+ next_btn = zoom_controls[/<button[^>]*>[^<]*›[^<]*<\/button>/m]
46
+ fail!("pan next button missing aria-label") unless next_btn && next_btn.include?("aria-label=")
47
+
48
+ today_btn = zoom_controls[/<button[^>]*>[^<]*Today[^<]*<\/button>/m]
49
+ fail!("Today button missing aria-label") unless today_btn && today_btn.include?("aria-label=")
50
+
51
+ # 3. Pointer and keyboard event handlers registered in the script
52
+ script_match = source[/<script>(.*?)<\/script>/m, 1]
53
+ fail!("inline script missing") unless script_match
54
+
55
+ fail!("script missing pointerdown handler") unless script_match.include?("pointerdown")
56
+ fail!("script missing pointermove handler") unless script_match.include?("pointermove")
57
+ fail!("script missing pointerup handler") unless script_match.include?("pointerup")
58
+ fail!("script missing keydown handler") unless script_match.include?("keydown")
59
+
60
+ # 4. shiftKey checked on the selection path
61
+ fail!("script does not check shiftKey for selection") unless script_match.include?("shiftKey")
62
+
63
+ # 5. changeZoom no longer calls scrollToToday()
64
+ # Brace-matched body, so a nested block cannot hide a later call.
65
+ def function_body(script, name)
66
+ head = script.index(/function\s+#{name}\s*\([^)]*\)\s*\{/)
67
+ return nil unless head
68
+
69
+ open_at = script.index("{", head)
70
+ depth = 0
71
+ script[open_at..].each_char.with_index do |char, offset|
72
+ depth += 1 if char == "{"
73
+ depth -= 1 if char == "}"
74
+ return script[open_at + 1, offset - 1] if depth.zero?
75
+ end
76
+ nil
77
+ end
78
+
79
+ change_zoom_body = function_body(script_match, "changeZoom")
80
+ fail!("missing changeZoom function") unless change_zoom_body
81
+ if change_zoom_body.include?("scrollToToday")
82
+ fail!("changeZoom must not call scrollToToday(); zoom must preserve center date")
83
+ end
84
+
85
+ # 6. prefers-reduced-motion check present
86
+ fail!("missing prefers-reduced-motion check") unless script_match.include?("prefers-reduced-motion")
87
+
88
+ # 7. Selection hint text present
89
+ fail!("missing hint text 'Shift+drag to select a period'") unless source.include?("Shift+drag to select a period")
90
+
91
+ # 8. Pan buttons expose their bound state to assistive tech
92
+ fail!("pan buttons must set aria-disabled at bounds") unless script_match.include?("aria-disabled")
93
+
94
+ # 9. A second boot (inline stub, then runtime JSON) must not stack listeners
95
+ boot_body = function_body(script_match, "bootTimeline")
96
+ fail!("missing bootTimeline function") unless boot_body
97
+ fail!("bootTimeline must abort the previous boot's listeners") unless boot_body.include?("AbortController") && boot_body.include?(".abort()")
98
+ %w[pointerdown pointermove keydown resize].each do |type|
99
+ head = boot_body.index(/addEventListener\('#{type}',\s*function\s*\([^)]*\)\s*\{/)
100
+ fail!("missing #{type} listener in bootTimeline") unless head
101
+ open_at = boot_body.index("{", head)
102
+ depth = 0
103
+ close_at = nil
104
+ boot_body[open_at..].each_char.with_index do |char, offset|
105
+ depth += 1 if char == "{"
106
+ depth -= 1 if char == "}"
107
+ if depth.zero?
108
+ close_at = open_at + offset
109
+ break
110
+ end
111
+ end
112
+ tail = close_at && boot_body[(close_at + 1)..]
113
+ fail!("#{type} listener must take the boot's abort signal") unless tail&.match?(/\A,\s*bootScope\s*\)/)
114
+ end
115
+
116
+ puts "PASS test-timeline-pan (PLT-AC-47)"
data/scripts/validate.sh CHANGED
@@ -71,6 +71,9 @@ TESTS=(
71
71
  scripts/test/test-css-delivery.rb
72
72
  scripts/test/test-discovery.rb
73
73
  scripts/test/test-hero-portraits.rb
74
+ scripts/test/test-thumbnail-variants.rb
75
+ scripts/test/test-highlighter-pills.rb
76
+ scripts/test/test-timeline-pan.rb
74
77
  )
75
78
 
76
79
  failed=0
@@ -91,6 +94,11 @@ if ! node scripts/test/test-hero-portraits.mjs; then
91
94
  failed=1
92
95
  fi
93
96
 
97
+ echo "-- scripts/test/test-poster-srcset.mjs"
98
+ if ! node scripts/test/test-poster-srcset.mjs; then
99
+ failed=1
100
+ fi
101
+
94
102
  echo "-- scripts/test/test-studio-pipeline-config.mjs"
95
103
  if ! node scripts/test/test-studio-pipeline-config.mjs; then
96
104
  failed=1
@@ -101,6 +109,11 @@ if ! node scripts/test/test-studio-pipeline-focus.mjs; then
101
109
  failed=1
102
110
  fi
103
111
 
112
+ echo "-- scripts/test/test-timeline-pan.mjs"
113
+ if ! node scripts/test/test-timeline-pan.mjs; then
114
+ failed=1
115
+ fi
116
+
104
117
  if [[ "$failed" -ne 0 ]]; then
105
118
  echo "VALIDATE FAILED"
106
119
  exit 1