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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/_includes/content-runtime/43-blocks.html +26 -4
- data/_includes/content-runtime/60-fragments.html +1 -0
- data/_includes/page/timeline-section.html +228 -29
- data/_layouts/projects.html +7 -1
- data/_layouts/shell.html +1 -1
- data/assets/css/base.css +174 -116
- data/assets/css/ledger/bibliography.css +15 -10
- data/assets/css/ledger/chrome.css +3 -12
- data/assets/css/ledger/cv.css +21 -0
- data/assets/css/ledger/detail.css +29 -15
- data/assets/css/ledger/home.css +6 -2
- data/assets/css/ledger/sources.css +27 -12
- data/assets/css/ledger/writing.css +10 -15
- data/lib/pandorga/cli.rb +4 -1
- data/lib/pandorga/commands/new.rb +1 -1
- data/lib/pandorga/commands/thumbnails.rb +19 -0
- data/lib/pandorga/thumbnail_variants.rb +267 -0
- data/lib/pandorga/version.rb +1 -1
- data/lib/pandorga.rb +1 -0
- data/scripts/content/export-content-json.rb +22 -1
- data/scripts/test/test-highlighter-pills.rb +306 -0
- data/scripts/test/test-poster-srcset.mjs +159 -0
- data/scripts/test/test-shell-a11y.rb +38 -0
- data/scripts/test/test-theme-contrast.rb +91 -0
- data/scripts/test/test-thumbnail-variants.rb +380 -0
- data/scripts/test/test-timeline-pan.mjs +147 -0
- data/scripts/test/test-timeline-pan.rb +116 -0
- data/scripts/validate.sh +13 -0
- metadata +8 -1
|
@@ -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
|